mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-04-25 23:49:59 +00:00
update workbench example
update aksk desc print res query logs Fix skills exec error with action type Update Skills Update Skills addr Change uni-lab. to leap-lab. Support unit in pylabrobot Support async func. change to leap-lab backend. Support feedback interval. Reduce cocurrent lags. fix create_resource_with_slot update unilabos_formulation & batch-submit-exp scale multi exec thread up to 48 update handle creation api fit cocurrent gap add running status debounce allow non @topic_config support update skill add placeholder keys always free 提交实验技能 disable samples correct sample demo ret value 新增试剂reagent update registry 新增manual_confirm add workstation creation skill add virtual_sample_demo 样品追踪测试设备 add external devices param fix registry upload missing type fast registry load minor fix on skill & registry stripe ros2 schema desc add create-device-skill new registry system backwards to yaml remove not exist resource new registry sys exp. support with add device correct raise create resource error ret info fix revert ret info fix fix prcxi check add create_resource schema re signal host ready event add websocket connection timeout and improve reconnection logic add open_timeout parameter to websocket connection add TimeoutError and InvalidStatus exception handling implement exponential backoff for reconnection attempts simplify reconnection logic flow
This commit is contained in:
160
.cursor/skills/add-device/SKILL.md
Normal file
160
.cursor/skills/add-device/SKILL.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
name: add-device
|
||||
description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses @device decorator + AST auto-scanning instead of manual YAML. Walks through device category, communication protocol, driver creation with decorators, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, or mentions 接入设备/添加设备/设备驱动/物模型.
|
||||
---
|
||||
|
||||
# 添加新设备到 Uni-Lab-OS
|
||||
|
||||
**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南。
|
||||
|
||||
该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。
|
||||
|
||||
---
|
||||
|
||||
## 装饰器参考
|
||||
|
||||
### @device — 设备类装饰器
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device
|
||||
|
||||
# 单设备
|
||||
@device(
|
||||
id="my_device.vendor", # 注册表唯一标识(必填)
|
||||
category=["temperature"], # 分类标签列表(必填)
|
||||
description="设备描述", # 设备描述
|
||||
display_name="显示名称", # UI 显示名称(默认用 id)
|
||||
icon="DeviceIcon.webp", # 图标文件名
|
||||
version="1.0.0", # 版本号
|
||||
device_type="python", # "python" 或 "ros2"
|
||||
handles=[...], # 端口列表(InputHandle / OutputHandle)
|
||||
model={...}, # 3D 模型配置
|
||||
hardware_interface=HardwareInterface(...), # 硬件通信接口
|
||||
)
|
||||
|
||||
# 多设备(同一个类注册多个设备 ID,各自有不同的 handles 等配置)
|
||||
@device(
|
||||
ids=["pump.vendor.model_A", "pump.vendor.model_B"],
|
||||
id_meta={
|
||||
"pump.vendor.model_A": {"handles": [...], "description": "型号 A"},
|
||||
"pump.vendor.model_B": {"handles": [...], "description": "型号 B"},
|
||||
},
|
||||
category=["pump_and_valve"],
|
||||
)
|
||||
```
|
||||
|
||||
### @action — 动作方法装饰器
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import action
|
||||
|
||||
@action # 无参:注册为 UniLabJsonCommand 动作
|
||||
@action() # 同上
|
||||
@action(description="执行操作") # 带描述
|
||||
@action(
|
||||
action_type=HeatChill, # 指定 ROS Action 消息类型
|
||||
goal={"temperature": "temp"}, # Goal 字段映射
|
||||
feedback={}, # Feedback 字段映射
|
||||
result={}, # Result 字段映射
|
||||
handles=[...], # 动作级别端口
|
||||
goal_default={"temp": 25.0}, # Goal 默认值
|
||||
placeholder_keys={...}, # 参数占位符
|
||||
always_free=True, # 不受排队限制
|
||||
auto_prefix=True, # 强制使用 auto- 前缀
|
||||
parent=True, # 从父类 MRO 获取参数签名
|
||||
)
|
||||
```
|
||||
|
||||
**自动识别规则:**
|
||||
- 带 `@action` 的公开方法 → 注册为动作(方法名即动作名)
|
||||
- **不带 `@action` 的公开方法** → 自动注册为 `auto-{方法名}` 动作
|
||||
- `_` 开头的方法 → 不扫描
|
||||
- `@not_action` 标记的方法 → 排除
|
||||
|
||||
### @topic_config — 状态属性配置
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
@property
|
||||
@topic_config(
|
||||
period=5.0, # 发布周期(秒),默认 5.0
|
||||
print_publish=False, # 是否打印发布日志
|
||||
qos=10, # QoS 深度,默认 10
|
||||
name="custom_name", # 自定义发布名称(默认用属性名)
|
||||
)
|
||||
def temperature(self) -> float:
|
||||
return self.data.get("temperature", 0.0)
|
||||
```
|
||||
|
||||
### 辅助装饰器
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import not_action, always_free
|
||||
|
||||
@not_action # 标记为非动作(post_init、辅助方法等)
|
||||
@always_free # 标记为不受排队限制(查询类操作)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 设备模板
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
from unilabos.registry.decorators import device, action, topic_config, not_action
|
||||
|
||||
@device(id="my_device", category=["my_category"], description="设备描述")
|
||||
class MyDevice:
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
self.device_id = device_id or "my_device"
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||
self.data: Dict[str, Any] = {"status": "Idle"}
|
||||
|
||||
@not_action
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode) -> None:
|
||||
self._ros_node = ros_node
|
||||
|
||||
@action
|
||||
async def initialize(self) -> bool:
|
||||
self.data["status"] = "Ready"
|
||||
return True
|
||||
|
||||
@action
|
||||
async def cleanup(self) -> bool:
|
||||
self.data["status"] = "Offline"
|
||||
return True
|
||||
|
||||
@action(description="执行操作")
|
||||
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
|
||||
"""带 @action 装饰器 → 注册为 'my_action' 动作"""
|
||||
return {"success": True}
|
||||
|
||||
def get_info(self) -> Dict[str, Any]:
|
||||
"""无 @action → 自动注册为 'auto-get_info' 动作"""
|
||||
return {"device_id": self.device_id}
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "Idle")
|
||||
|
||||
@property
|
||||
@topic_config(period=2.0)
|
||||
def temperature(self) -> float:
|
||||
return self.data.get("temperature", 0.0)
|
||||
```
|
||||
|
||||
### 要点
|
||||
|
||||
- `_ros_node: BaseROS2DeviceNode` 类型标注放在类体顶部
|
||||
- `__init__` 签名固定为 `(self, device_id=None, config=None, **kwargs)`
|
||||
- `post_init` 用 `@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode`
|
||||
- 运行时状态存储在 `self.data` 字典中
|
||||
- 设备文件放在 `unilabos/devices/<category>/` 目录下
|
||||
351
.cursor/skills/add-resource/SKILL.md
Normal file
351
.cursor/skills/add-resource/SKILL.md
Normal file
@@ -0,0 +1,351 @@
|
||||
---
|
||||
name: add-resource
|
||||
description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Uses @resource decorator for AST auto-scanning. Covers Bottle, Carrier, Deck, WareHouse definitions. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse.
|
||||
---
|
||||
|
||||
# 添加新物料资源
|
||||
|
||||
Uni-Lab-OS 的资源体系基于 PyLabRobot,通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。使用 `@resource` 装饰器注册,AST 自动扫描生成注册表条目。
|
||||
|
||||
---
|
||||
|
||||
## 资源类型
|
||||
|
||||
| 类型 | 基类 | 用途 | 示例 |
|
||||
|------|------|------|------|
|
||||
| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 |
|
||||
| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle) | 6 位试剂架、枪头盒 |
|
||||
| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier) | 4x4 堆栈 |
|
||||
| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse) | 反应站 Deck |
|
||||
|
||||
**层级关系:** `Deck` → `WareHouse` → `BottleCarrier` → `Bottle`
|
||||
|
||||
WareHouse 本质上和 Site 是同一概念 — 都是定义一组固定的放置位(slot),只不过 WareHouse 多嵌套了一层 Deck。两者都需要开发者根据实际物理尺寸自行计算各 slot 的偏移坐标。
|
||||
|
||||
---
|
||||
|
||||
## @resource 装饰器
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
@resource(
|
||||
id="my_resource_id", # 注册表唯一标识(必填)
|
||||
category=["bottles"], # 分类标签列表(必填)
|
||||
description="资源描述",
|
||||
icon="", # 图标
|
||||
version="1.0.0",
|
||||
handles=[...], # 端口列表(InputHandle / OutputHandle)
|
||||
model={...}, # 3D 模型配置
|
||||
class_type="pylabrobot", # "python" / "pylabrobot" / "unilabos"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 创建规范
|
||||
|
||||
### 命名规则
|
||||
|
||||
1. **`name` 参数作为前缀**:所有工厂函数必须接受 `name: str` 参数,创建子物料时以 `name` 作为前缀,确保实例名在运行时全局唯一
|
||||
2. **Bottle 命名约定**:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
||||
3. **函数名 = `@resource(id=...)`**:工厂函数名与注册表 id 保持一致
|
||||
|
||||
### 子物料命名示例
|
||||
|
||||
```python
|
||||
# Carrier 内部的 sites 用 name 前缀
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}" # "堆栈1左_A01", "堆栈1左_B02" ...
|
||||
|
||||
# Carrier 中放置 Bottle 时用 name 前缀
|
||||
carrier[0] = My_Reagent_Bottle(f"{name}_flask_1") # "堆栈1左_flask_1"
|
||||
carrier[i] = My_Solid_Vial(f"{name}_vial_{ordering[i]}") # "堆栈1左_vial_A1"
|
||||
|
||||
# create_homogeneous_resources 使用 name_prefix
|
||||
sites=create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=[...],
|
||||
name_prefix=name, # 自动生成 "{name}_0", "{name}_1" ...
|
||||
)
|
||||
|
||||
# Deck setup 中用仓库名称作为 name 传入
|
||||
self.warehouses = {
|
||||
"堆栈1左": my_warehouse_4x4("堆栈1左"), # WareHouse.name = "堆栈1左"
|
||||
"试剂堆栈": my_reagent_stack("试剂堆栈"), # WareHouse.name = "试剂堆栈"
|
||||
}
|
||||
```
|
||||
|
||||
### 其他规范
|
||||
|
||||
- **max_volume 单位为 μL**:500mL = 500000
|
||||
- **尺寸单位为 mm**:`diameter`, `height`, `size_x/y/z`, `dx/dy/dz`
|
||||
- **BottleCarrier 必须设置 `num_items_x/y/z`**:用于前端渲染布局
|
||||
- **Deck 的 `__init__` 必须接受 `setup=False`**:图文件中 `config.setup=true` 触发 `setup()`
|
||||
- **按项目分组文件**:同一工作站的资源放在 `unilabos/resources/<project>/` 下
|
||||
- **`__init__` 必须接受 `serialize()` 输出的所有字段**:`serialize()` 输出会作为 `config` 回传到 `__init__`,因此必须通过显式参数或 `**kwargs` 接受,否则反序列化会报错
|
||||
- **持久化运行时状态用 `serialize_state()`**:通过 `_unilabos_state` 字典存储可变信息(如物料内容、液体量),只存 JSON 可序列化的基本类型
|
||||
|
||||
---
|
||||
|
||||
## 资源模板
|
||||
|
||||
### Bottle
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import resource
|
||||
from unilabos.resources.itemized_carrier import Bottle
|
||||
|
||||
|
||||
@resource(id="My_Reagent_Bottle", category=["bottles"], description="我的试剂瓶")
|
||||
def My_Reagent_Bottle(
|
||||
name: str,
|
||||
diameter: float = 70.0,
|
||||
height: float = 120.0,
|
||||
max_volume: float = 500000.0,
|
||||
barcode: str = None,
|
||||
) -> Bottle:
|
||||
return Bottle(
|
||||
name=name,
|
||||
diameter=diameter,
|
||||
height=height,
|
||||
max_volume=max_volume,
|
||||
barcode=barcode,
|
||||
model="My_Reagent_Bottle",
|
||||
)
|
||||
```
|
||||
|
||||
**Bottle 参数:**
|
||||
- `name`: 实例名称(运行时唯一,由上层 Carrier 以前缀方式传入)
|
||||
- `diameter`: 瓶体直径 (mm)
|
||||
- `height`: 瓶体高度 (mm)
|
||||
- `max_volume`: 最大容积(**μL**,500mL = 500000)
|
||||
- `barcode`: 条形码(可选)
|
||||
|
||||
### BottleCarrier
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import ResourceHolder
|
||||
from pylabrobot.resources.carrier import create_ordered_items_2d
|
||||
from unilabos.resources.itemized_carrier import BottleCarrier
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
|
||||
@resource(id="My_6SlotCarrier", category=["bottle_carriers"], description="六槽位载架")
|
||||
def My_6SlotCarrier(name: str) -> BottleCarrier:
|
||||
sites = create_ordered_items_2d(
|
||||
klass=ResourceHolder,
|
||||
num_items_x=3, num_items_y=2,
|
||||
dx=10.0, dy=10.0, dz=5.0,
|
||||
item_dx=42.0, item_dy=35.0,
|
||||
size_x=20.0, size_y=20.0, size_z=50.0,
|
||||
)
|
||||
# 子 site 用 name 作为前缀
|
||||
for k, v in sites.items():
|
||||
v.name = f"{name}_{v.name}"
|
||||
|
||||
carrier = BottleCarrier(
|
||||
name=name, size_x=146.0, size_y=80.0, size_z=55.0,
|
||||
sites=sites, model="My_6SlotCarrier",
|
||||
)
|
||||
carrier.num_items_x = 3
|
||||
carrier.num_items_y = 2
|
||||
carrier.num_items_z = 1
|
||||
|
||||
# 放置 Bottle 时用 name 作为前缀
|
||||
ordering = ["A1", "B1", "A2", "B2", "A3", "B3"]
|
||||
for i in range(6):
|
||||
carrier[i] = My_Reagent_Bottle(f"{name}_vial_{ordering[i]}")
|
||||
return carrier
|
||||
```
|
||||
|
||||
### WareHouse / Deck 放置位
|
||||
|
||||
WareHouse 和 Site 本质上是同一概念:都是定义一组固定放置位(slot),根据物理尺寸自行批量计算偏移坐标。WareHouse 只是多嵌套了一层 Deck 而已。推荐开发者直接根据实物测量数据计算各 slot 偏移量。
|
||||
|
||||
#### WareHouse(使用 warehouse_factory)
|
||||
|
||||
```python
|
||||
from unilabos.resources.warehouse import warehouse_factory
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
|
||||
@resource(id="my_warehouse_4x4", category=["warehouse"], description="4x4 堆栈仓库")
|
||||
def my_warehouse_4x4(name: str) -> "WareHouse":
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0, # 第一个 slot 的起始偏移
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0, # slot 间距
|
||||
resource_size_x=127.0, resource_size_y=85.0, resource_size_z=100.0, # slot 尺寸
|
||||
model="my_warehouse_4x4",
|
||||
col_offset=0, # 列标签起始偏移(0 → A01, 4 → A05)
|
||||
layout="row-major", # "row-major" 行优先 / "col-major" 列优先 / "vertical-col-major" 竖向
|
||||
)
|
||||
```
|
||||
|
||||
`warehouse_factory` 参数说明:
|
||||
- `dx/dy/dz`:第一个 slot 相对 WareHouse 原点的偏移(mm)
|
||||
- `item_dx/item_dy/item_dz`:相邻 slot 间距(mm),需根据实际物理间距测量
|
||||
- `resource_size_x/y/z`:每个 slot 的可放置区域尺寸
|
||||
- `layout`:影响 slot 标签和坐标映射
|
||||
- `"row-major"`:A01,A02,...,B01,B02,...(行优先,适合横向排列)
|
||||
- `"col-major"`:A01,B01,...,A02,B02,...(列优先)
|
||||
- `"vertical-col-major"`:竖向排列,y 坐标反向
|
||||
|
||||
#### Deck 组装 WareHouse
|
||||
|
||||
Deck 通过 `setup()` 将多个 WareHouse 放置到指定坐标:
|
||||
|
||||
```python
|
||||
from pylabrobot.resources import Deck, Coordinate
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
|
||||
@resource(id="MyStation_Deck", category=["deck"], description="我的工作站 Deck")
|
||||
class MyStation_Deck(Deck):
|
||||
def __init__(self, name="MyStation_Deck", size_x=2700.0, size_y=1080.0, size_z=1500.0,
|
||||
category="deck", setup=False, **kwargs) -> None:
|
||||
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
|
||||
if setup:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
self.warehouses = {
|
||||
"堆栈1左": my_warehouse_4x4("堆栈1左"),
|
||||
"堆栈1右": my_warehouse_4x4("堆栈1右"),
|
||||
}
|
||||
self.warehouse_locations = {
|
||||
"堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 自行测量计算
|
||||
"堆栈1右": Coordinate(2350.0, 400.0, 0.0),
|
||||
}
|
||||
for wh_name, wh in self.warehouses.items():
|
||||
self.assign_child_resource(wh, location=self.warehouse_locations[wh_name])
|
||||
```
|
||||
|
||||
#### Site 模式(前端定向放置)
|
||||
|
||||
适用于有固定孔位/槽位的设备(如移液站 PRCXI 9300),Deck 通过 `sites` 列表定义前端展示的放置位,前端据此渲染可拖拽的孔位布局:
|
||||
|
||||
```python
|
||||
import collections
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pylabrobot.resources import Deck, Resource, Coordinate
|
||||
from unilabos.registry.decorators import resource
|
||||
|
||||
|
||||
@resource(id="MyLabDeck", category=["deck"], description="带 Site 定向放置的 Deck")
|
||||
class MyLabDeck(Deck):
|
||||
# 根据设备台面实测批量计算各 slot 坐标偏移
|
||||
_DEFAULT_SITE_POSITIONS = [
|
||||
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
|
||||
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
|
||||
]
|
||||
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86.0, "depth": 0}
|
||||
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "tube_rack", "adaptor"]
|
||||
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
|
||||
super().__init__(size_x, size_y, size_z, name)
|
||||
if sites is not None:
|
||||
self.sites = [dict(s) for s in sites]
|
||||
else:
|
||||
self.sites = []
|
||||
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
|
||||
self.sites.append({
|
||||
"label": f"T{i + 1}", # 前端显示的槽位标签
|
||||
"visible": True, # 是否在前端可见
|
||||
"position": {"x": x, "y": y, "z": z}, # 槽位物理坐标
|
||||
"size": dict(self._DEFAULT_SITE_SIZE), # 槽位尺寸
|
||||
"content_type": list(self._DEFAULT_CONTENT_TYPE), # 允许放入的物料类型
|
||||
})
|
||||
self._ordering = collections.OrderedDict(
|
||||
(site["label"], None) for site in self.sites
|
||||
)
|
||||
|
||||
def assign_child_resource(self, resource: Resource,
|
||||
location: Optional[Coordinate] = None,
|
||||
reassign: bool = True,
|
||||
spot: Optional[int] = None):
|
||||
idx = spot
|
||||
if spot is None:
|
||||
for i, site in enumerate(self.sites):
|
||||
if site.get("label") == resource.name:
|
||||
idx = i
|
||||
break
|
||||
if idx is None:
|
||||
for i in range(len(self.sites)):
|
||||
if self._get_site_resource(i) is None:
|
||||
idx = i
|
||||
break
|
||||
if idx is None:
|
||||
raise ValueError(f"No available site for '{resource.name}'")
|
||||
loc = Coordinate(**self.sites[idx]["position"])
|
||||
super().assign_child_resource(resource, location=loc, reassign=reassign)
|
||||
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
sites_out = []
|
||||
for i, site in enumerate(self.sites):
|
||||
occupied = self._get_site_resource(i)
|
||||
sites_out.append({
|
||||
"label": site["label"],
|
||||
"visible": site.get("visible", True),
|
||||
"occupied_by": occupied.name if occupied else None,
|
||||
"position": site["position"],
|
||||
"size": site["size"],
|
||||
"content_type": site["content_type"],
|
||||
})
|
||||
data["sites"] = sites_out
|
||||
return data
|
||||
```
|
||||
|
||||
**Site 字段说明:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `label` | str | 槽位标签(如 `"T1"`),前端显示名称,也用于匹配 resource.name |
|
||||
| `visible` | bool | 是否在前端可见 |
|
||||
| `position` | dict | 物理坐标 `{x, y, z}`(mm),需自行测量计算偏移 |
|
||||
| `size` | dict | 槽位尺寸 `{width, height, depth}`(mm) |
|
||||
| `content_type` | list | 允许放入的物料类型,如 `["plate", "tip_rack", "tube_rack", "adaptor"]` |
|
||||
|
||||
**参考实现:** `unilabos/devices/liquid_handling/prcxi/prcxi.py` 中的 `PRCXI9300Deck`(4x4 共 16 个 site)。
|
||||
|
||||
---
|
||||
|
||||
## 文件位置
|
||||
|
||||
```
|
||||
unilabos/resources/
|
||||
├── <project>/ # 按项目分组
|
||||
│ ├── bottles.py # Bottle 工厂函数
|
||||
│ ├── bottle_carriers.py # Carrier 工厂函数
|
||||
│ ├── warehouses.py # WareHouse 工厂函数
|
||||
│ └── decks.py # Deck 类定义
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 资源可导入
|
||||
python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))"
|
||||
|
||||
# 启动测试(AST 自动扫描)
|
||||
unilab -g <graph>.json
|
||||
```
|
||||
|
||||
仅在以下情况仍需 YAML:第三方库资源(如 pylabrobot 内置资源,无 `@resource` 装饰器)。
|
||||
|
||||
---
|
||||
|
||||
## 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
||||
| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` |
|
||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
||||
| 装饰器定义 | `unilabos/registry/decorators.py` |
|
||||
292
.cursor/skills/add-resource/reference.md
Normal file
292
.cursor/skills/add-resource/reference.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# 资源高级参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 类继承体系
|
||||
|
||||
```
|
||||
PyLabRobot
|
||||
├── Resource (PLR 基类)
|
||||
│ ├── Well
|
||||
│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器
|
||||
│ ├── Deck
|
||||
│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面
|
||||
│ ├── ResourceHolder → 槽位占位符
|
||||
│ └── Container
|
||||
│ └── Battery (unilabos) → 组装好的电池
|
||||
│
|
||||
├── ItemizedCarrier (unilabos, 继承 Resource)
|
||||
│ ├── BottleCarrier (unilabos) → 瓶载架
|
||||
│ └── WareHouse (unilabos) → 堆栈仓库
|
||||
│
|
||||
├── ItemizedResource (PLR)
|
||||
│ └── MagazineHolder (unilabos) → 子弹夹载架
|
||||
│
|
||||
└── ResourceStack (PLR)
|
||||
└── Magazine (unilabos) → 子弹夹洞位
|
||||
```
|
||||
|
||||
### Bottle 类细节
|
||||
|
||||
```python
|
||||
class Bottle(Well):
|
||||
def __init__(self, name, diameter, height, max_volume,
|
||||
size_x=0.0, size_y=0.0, size_z=0.0,
|
||||
barcode=None, category="container", model=None, **kwargs):
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=diameter, # PLR 用 diameter 作为 size_x/size_y
|
||||
size_y=diameter,
|
||||
size_z=height, # PLR 用 height 作为 size_z
|
||||
max_volume=max_volume,
|
||||
category=category,
|
||||
model=model,
|
||||
bottom_type="flat",
|
||||
cross_section_type="circle"
|
||||
)
|
||||
```
|
||||
|
||||
注意 `size_x = size_y = diameter`,`size_z = height`。
|
||||
|
||||
### ItemizedCarrier 核心方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 |
|
||||
| `__setitem__(identifier, resource)` | 向槽位放入资源 |
|
||||
| `get_child_identifier(child)` | 获取子资源的标识符 |
|
||||
| `capacity` | 总槽位数 |
|
||||
| `sites` | 所有槽位字典 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 序列化与反序列化
|
||||
|
||||
### PLR ↔ UniLab 转换
|
||||
|
||||
| 函数 | 位置 | 方向 |
|
||||
|------|------|------|
|
||||
| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab |
|
||||
| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR |
|
||||
|
||||
### `from_plr_resources` 流程
|
||||
|
||||
```
|
||||
PLR Resource
|
||||
↓ build_uuid_mapping (递归生成 UUID)
|
||||
↓ resource.serialize() → dict
|
||||
↓ resource.serialize_all_state() → states
|
||||
↓ resource_plr_inner (递归构建 ResourceDictInstance)
|
||||
ResourceTreeSet
|
||||
```
|
||||
|
||||
关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID,`unilabos_extra` 携带扩展数据(如 `class` 名)。
|
||||
|
||||
### `to_plr_resources` 流程
|
||||
|
||||
```
|
||||
ResourceTreeSet
|
||||
↓ collect_node_data (收集 UUID、状态、扩展数据)
|
||||
↓ node_to_plr_dict (转为 PLR 字典格式)
|
||||
↓ find_subclass(type_name, PLRResource) (查找 PLR 子类)
|
||||
↓ sub_cls.deserialize(plr_dict) (反序列化)
|
||||
↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展)
|
||||
PLR Resource
|
||||
```
|
||||
|
||||
### Bottle 序列化
|
||||
|
||||
```python
|
||||
class Bottle(Well):
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
return {**data, "diameter": self.diameter, "height": self.height}
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: dict, allow_marshal=False):
|
||||
barcode_data = data.pop("barcode", None)
|
||||
instance = super().deserialize(data, allow_marshal=allow_marshal)
|
||||
if barcode_data and isinstance(barcode_data, str):
|
||||
instance.barcode = barcode_data
|
||||
return instance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Bioyond 物料同步
|
||||
|
||||
### 双向转换函数
|
||||
|
||||
| 函数 | 位置 | 方向 |
|
||||
|------|------|------|
|
||||
| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR |
|
||||
| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond |
|
||||
|
||||
### `resource_bioyond_to_plr` 流程
|
||||
|
||||
```
|
||||
Bioyond 物料列表
|
||||
↓ reverse_type_mapping: {typeName → (model, UUID)}
|
||||
↓ 对每个物料:
|
||||
typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor")
|
||||
initialize_resource({"name": unique_name, "class": model})
|
||||
↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等)
|
||||
↓ 处理 detail (子物料/坐标)
|
||||
↓ 按 locationName 放入 deck.warehouses 对应槽位
|
||||
PLR 资源列表
|
||||
```
|
||||
|
||||
### `resource_plr_to_bioyond` 流程
|
||||
|
||||
```
|
||||
PLR 资源列表
|
||||
↓ 遍历每个资源:
|
||||
载架(capacity > 1): 生成 details 子物料 + 坐标
|
||||
单瓶: 直接映射
|
||||
↓ type_mapping 查找 typeId
|
||||
↓ warehouse_mapping 查找位置 UUID
|
||||
↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations)
|
||||
Bioyond 物料列表
|
||||
```
|
||||
|
||||
### BioyondResourceSynchronizer
|
||||
|
||||
工作站通过 `ResourceSynchronizer` 自动同步物料:
|
||||
|
||||
```python
|
||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
def sync_from_external(self) -> bool:
|
||||
all_data = []
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品
|
||||
all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂
|
||||
unilab_resources = resource_bioyond_to_plr(
|
||||
all_data,
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
deck=self.workstation.deck
|
||||
)
|
||||
# 更新 deck 上的资源
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 非瓶类资源
|
||||
|
||||
### ElectrodeSheet(极片)
|
||||
|
||||
路径:`unilabos/resources/battery/electrode_sheet.py`
|
||||
|
||||
```python
|
||||
class ElectrodeSheet(ResourcePLR):
|
||||
"""片状材料(极片、隔膜、弹片、垫片等)"""
|
||||
_unilabos_state = {
|
||||
"diameter": 0.0,
|
||||
"thickness": 0.0,
|
||||
"mass": 0.0,
|
||||
"material_type": "",
|
||||
"color": "",
|
||||
"info": "",
|
||||
}
|
||||
```
|
||||
|
||||
工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil`
|
||||
|
||||
### Battery(电池)
|
||||
|
||||
```python
|
||||
class Battery(Container):
|
||||
"""组装好的电池"""
|
||||
_unilabos_state = {
|
||||
"color": "",
|
||||
"electrolyte_name": "",
|
||||
"open_circuit_voltage": 0.0,
|
||||
}
|
||||
```
|
||||
|
||||
### Magazine / MagazineHolder(子弹夹)
|
||||
|
||||
```python
|
||||
class Magazine(ResourceStack):
|
||||
"""子弹夹洞位,可堆叠 ElectrodeSheet"""
|
||||
# direction, max_sheets
|
||||
|
||||
class MagazineHolder(ItemizedResource):
|
||||
"""多洞位子弹夹"""
|
||||
# hole_diameter, hole_depth, max_sheets_per_hole
|
||||
```
|
||||
|
||||
工厂函数 `magazine_factory()` 用 `create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet` 或 `Battery`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 仓库工厂模式参考
|
||||
|
||||
### 实际 warehouse 工厂函数示例
|
||||
|
||||
```python
|
||||
# 行优先 4x4 仓库
|
||||
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
layout="row-major", # A01,A02,A03,A04, B01,...
|
||||
)
|
||||
|
||||
# 右侧 4x4 仓库(列名偏移)
|
||||
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
col_offset=4, # A05,A06,A07,A08
|
||||
layout="row-major",
|
||||
)
|
||||
|
||||
# 竖向仓库(站内试剂存放)
|
||||
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=1, num_items_y=2, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||
layout="vertical-col-major",
|
||||
)
|
||||
|
||||
# 行偏移(F 行开始)
|
||||
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
||||
return warehouse_factory(
|
||||
name=name,
|
||||
num_items_x=3, num_items_y=5, num_items_z=1,
|
||||
dx=10.0, dy=10.0, dz=10.0,
|
||||
item_dx=159.0, item_dy=183.0, item_dz=130.0,
|
||||
row_offset=row_offset, # 0→A行起,5→F行起
|
||||
layout="row-major",
|
||||
)
|
||||
```
|
||||
|
||||
### layout 类型说明
|
||||
|
||||
| layout | 命名顺序 | 适用场景 |
|
||||
|--------|---------|---------|
|
||||
| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 |
|
||||
| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先,Bioyond 前端展示 |
|
||||
| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键路径
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
||||
| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` |
|
||||
| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` |
|
||||
| Bioyond 物料转换 | `unilabos/resources/graphio.py` |
|
||||
| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` |
|
||||
| 电池资源 | `unilabos/resources/battery/` |
|
||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
||||
626
.cursor/skills/add-workstation/SKILL.md
Normal file
626
.cursor/skills/add-workstation/SKILL.md
Normal file
@@ -0,0 +1,626 @@
|
||||
---
|
||||
name: add-workstation
|
||||
description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Uses @device decorator + AST auto-scanning. Walks through workstation type, sub-device composition, driver creation, deck setup, and graph file. Use when the user wants to add a workstation, create a workstation driver, configure a station with sub-devices, or mentions 工作站/工站/station/workstation.
|
||||
---
|
||||
|
||||
# Uni-Lab-OS 工作站接入指南
|
||||
|
||||
工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统和工作流引擎。使用 `@device` 装饰器注册,AST 自动扫描生成注册表。
|
||||
|
||||
---
|
||||
|
||||
## 工作站类型
|
||||
|
||||
| 类型 | 基类 | 适用场景 |
|
||||
| ------------------- | ----------------- | ---------------------------------- |
|
||||
| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(泵转移、过滤等) |
|
||||
| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 对接 |
|
||||
| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件 |
|
||||
|
||||
---
|
||||
|
||||
## @device 装饰器(工作站)
|
||||
|
||||
工作站也使用 `@device` 装饰器注册,参数与普通设备一致:
|
||||
|
||||
```python
|
||||
@device(
|
||||
id="my_workstation", # 注册表唯一标识(必填)
|
||||
category=["workstation"], # 分类标签
|
||||
description="我的工作站",
|
||||
)
|
||||
```
|
||||
|
||||
如果一个工作站类支持多个具体变体,可使用 `ids` / `id_meta`,与设备的用法相同(参见 add-device SKILL)。
|
||||
|
||||
---
|
||||
|
||||
## 工作站驱动模板
|
||||
|
||||
### 模板 A:基于外部系统的工作站
|
||||
|
||||
```python
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from pylabrobot.resources import Deck
|
||||
|
||||
from unilabos.registry.decorators import device, topic_config, not_action
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
|
||||
try:
|
||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
except ImportError:
|
||||
ROS2WorkstationNode = None
|
||||
|
||||
|
||||
@device(id="my_workstation", category=["workstation"], description="我的工作站")
|
||||
class MyWorkstation(WorkstationBase):
|
||||
_ros_node: "ROS2WorkstationNode"
|
||||
|
||||
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger("MyWorkstation")
|
||||
self.api_host = self.config.get("api_host", "")
|
||||
self._status = "Idle"
|
||||
|
||||
@not_action
|
||||
def post_init(self, ros_node: "ROS2WorkstationNode"):
|
||||
super().post_init(ros_node)
|
||||
self._ros_node = ros_node
|
||||
|
||||
async def scheduler_start(self, **kwargs) -> Dict[str, Any]:
|
||||
"""注册为工作站动作"""
|
||||
return {"success": True}
|
||||
|
||||
async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]:
|
||||
"""注册为工作站动作"""
|
||||
return {"success": True}
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def workflow_sequence(self) -> str:
|
||||
return "[]"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def material_info(self) -> str:
|
||||
return "{}"
|
||||
```
|
||||
|
||||
### 模板 B:Protocol 工作站
|
||||
|
||||
直接使用 `ProtocolNode`,通常不需要自定义驱动类:
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import ProtocolNode
|
||||
```
|
||||
|
||||
在图文件中配置 `protocol_type` 即可。
|
||||
|
||||
---
|
||||
|
||||
## 子设备访问(sub_devices)
|
||||
|
||||
工站初始化子设备后,所有子设备实例存储在 `self._ros_node.sub_devices` 字典中(key 为设备 id,value 为 `ROS2DeviceNode` 实例)。工站的驱动类可以直接获取子设备实例来调用其方法:
|
||||
|
||||
```python
|
||||
# 在工站驱动类的方法中访问子设备
|
||||
sub = self._ros_node.sub_devices["pump_1"]
|
||||
|
||||
# .driver_instance — 子设备的驱动实例(即设备 Python 类的实例)
|
||||
sub.driver_instance.some_method(arg1, arg2)
|
||||
|
||||
# .ros_node_instance — 子设备的 ROS2 节点实例
|
||||
sub.ros_node_instance._action_value_mappings # 查看子设备支持的 action
|
||||
```
|
||||
|
||||
**常见用法**:
|
||||
|
||||
```python
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def my_protocol(self, **kwargs):
|
||||
# 获取子设备驱动实例
|
||||
pump = self._ros_node.sub_devices["pump_1"].driver_instance
|
||||
heater = self._ros_node.sub_devices["heater_1"].driver_instance
|
||||
|
||||
# 直接调用子设备方法
|
||||
pump.aspirate(volume=100)
|
||||
heater.set_temperature(80)
|
||||
```
|
||||
|
||||
> 参考实现:`unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py` 中通过 `self._ros_node.sub_devices.get(reactor_id)` 获取子反应器实例并更新数据。
|
||||
|
||||
---
|
||||
|
||||
## 硬件通信接口(hardware_interface)
|
||||
|
||||
硬件控制型工作站通常需要通过串口(Serial)、Modbus 等通信协议控制多个子设备。Uni-Lab-OS 通过 **通信设备代理** 机制实现端口共享:一个串口只创建一个 `serial` 节点,多个子设备共享这个通信实例。
|
||||
|
||||
### 工作原理
|
||||
|
||||
`ROS2WorkstationNode` 初始化时分两轮遍历子设备(`workstation.py`):
|
||||
|
||||
**第一轮 — 初始化所有子设备**:按 `children` 顺序调用 `initialize_device()`,通信设备(`serial_` / `io_` 开头的 id)优先完成初始化,创建 `serial.Serial()` 实例。其他子设备此时 `self.hardware_interface = "serial_pump"`(字符串)。
|
||||
|
||||
**第二轮 — 代理替换**:遍历所有已初始化的子设备,读取子设备的 `_hardware_interface` 配置:
|
||||
|
||||
```
|
||||
hardware_interface = d.ros_node_instance._hardware_interface
|
||||
# → {"name": "hardware_interface", "read": "send_command", "write": "send_command"}
|
||||
```
|
||||
|
||||
1. 取 `name` 字段对应的属性值:`name_value = getattr(driver, hardware_interface["name"])`
|
||||
- 如果 `name_value` 是字符串且该字符串是某个子设备的 id → 触发代理替换
|
||||
2. 从通信设备获取真正的 `read`/`write` 方法
|
||||
3. 用 `setattr(driver, read_method, _read)` 将通信设备的方法绑定到子设备上
|
||||
|
||||
因此:
|
||||
|
||||
- **通信设备 id 必须与子设备 config 中填的字符串完全一致**(如 `"serial_pump"`)
|
||||
- **通信设备 id 必须以 `serial_` 或 `io_` 开头**(否则第一轮不会被识别为通信设备)
|
||||
- **通信设备必须在 `children` 列表中排在最前面**,确保先初始化
|
||||
|
||||
### HardwareInterface 参数说明
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import HardwareInterface
|
||||
|
||||
HardwareInterface(
|
||||
name="hardware_interface", # __init__ 中接收通信实例的属性名
|
||||
read="send_command", # 通信设备上暴露的读方法名
|
||||
write="send_command", # 通信设备上暴露的写方法名
|
||||
extra_info=["list_ports"], # 可选:额外暴露的方法
|
||||
)
|
||||
```
|
||||
|
||||
**`name` 字段的含义**:对应设备类 `__init__` 中,用于保存通信实例的**属性名**。系统据此知道要替换哪个属性。大部分设备直接用 `"hardware_interface"`,也可以自定义(如 `"io_device_port"`)。
|
||||
|
||||
### 示例 1:泵(name="hardware_interface")
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device, HardwareInterface
|
||||
|
||||
@device(
|
||||
id="my_pump",
|
||||
category=["pump_and_valve"],
|
||||
hardware_interface=HardwareInterface(
|
||||
name="hardware_interface",
|
||||
read="send_command",
|
||||
write="send_command",
|
||||
),
|
||||
)
|
||||
class MyPump:
|
||||
def __init__(self, port=None, address="1", **kwargs):
|
||||
# name="hardware_interface" → 系统替换 self.hardware_interface
|
||||
self.hardware_interface = port # 初始为字符串 "serial_pump",启动后被替换为 Serial 实例
|
||||
self.address = address
|
||||
|
||||
def send_command(self, command: str):
|
||||
full_command = f"/{self.address}{command}\r\n"
|
||||
self.hardware_interface.write(bytearray(full_command, "ascii"))
|
||||
return self.hardware_interface.read_until(b"\n")
|
||||
```
|
||||
|
||||
### 示例 2:电磁阀(name="io_device_port",自定义属性名)
|
||||
|
||||
```python
|
||||
@device(
|
||||
id="solenoid_valve",
|
||||
category=["pump_and_valve"],
|
||||
hardware_interface=HardwareInterface(
|
||||
name="io_device_port", # 自定义属性名 → 系统替换 self.io_device_port
|
||||
read="read_io_coil",
|
||||
write="write_io_coil",
|
||||
),
|
||||
)
|
||||
class SolenoidValve:
|
||||
def __init__(self, io_device_port: str = None, **kwargs):
|
||||
# name="io_device_port" → 图文件 config 中用 "io_device_port": "io_board_1"
|
||||
self.io_device_port = io_device_port # 初始为字符串,系统替换为 Modbus 实例
|
||||
```
|
||||
|
||||
### Serial 通信设备(class="serial")
|
||||
|
||||
`serial` 是 Uni-Lab-OS 内置的通信代理设备,代码位于 `unilabos/ros/nodes/presets/serial_node.py`:
|
||||
|
||||
```python
|
||||
from serial import Serial, SerialException
|
||||
from threading import Lock
|
||||
|
||||
class ROS2SerialNode(BaseROS2DeviceNode):
|
||||
def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, **kwargs):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self._hardware_interface = {
|
||||
"name": "hardware_interface",
|
||||
"write": "send_command",
|
||||
"read": "read_data",
|
||||
}
|
||||
self._query_lock = Lock()
|
||||
|
||||
self.hardware_interface = Serial(baudrate=baudrate, port=port)
|
||||
|
||||
BaseROS2DeviceNode.__init__(
|
||||
self, driver_instance=self, registry_name=registry_name,
|
||||
device_id=device_id, status_types={}, action_value_mappings={},
|
||||
hardware_interface=self._hardware_interface, print_publish=False,
|
||||
)
|
||||
self.create_service(SerialCommand, "serialwrite", self.handle_serial_request)
|
||||
|
||||
def send_command(self, command: str):
|
||||
with self._query_lock:
|
||||
self.hardware_interface.write(bytearray(f"{command}\n", "ascii"))
|
||||
return self.hardware_interface.read_until(b"\n").decode()
|
||||
|
||||
def read_data(self):
|
||||
with self._query_lock:
|
||||
return self.hardware_interface.read_until(b"\n").decode()
|
||||
```
|
||||
|
||||
在图文件中使用 `"class": "serial"` 即可创建串口代理:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "serial_pump",
|
||||
"class": "serial",
|
||||
"parent": "my_station",
|
||||
"config": { "port": "COM7", "baudrate": 9600 }
|
||||
}
|
||||
```
|
||||
|
||||
### 图文件配置
|
||||
|
||||
**通信设备必须在 `children` 列表中排在最前面**,确保先于其他子设备初始化:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "my_station",
|
||||
"class": "workstation",
|
||||
"children": ["serial_pump", "pump_1", "pump_2"],
|
||||
"config": { "protocol_type": ["PumpTransferProtocol"] }
|
||||
},
|
||||
{
|
||||
"id": "serial_pump",
|
||||
"class": "serial",
|
||||
"parent": "my_station",
|
||||
"config": { "port": "COM7", "baudrate": 9600 }
|
||||
},
|
||||
{
|
||||
"id": "pump_1",
|
||||
"class": "syringe_pump_with_valve.runze.SY03B-T08",
|
||||
"parent": "my_station",
|
||||
"config": { "port": "serial_pump", "address": "1", "max_volume": 25.0 }
|
||||
},
|
||||
{
|
||||
"id": "pump_2",
|
||||
"class": "syringe_pump_with_valve.runze.SY03B-T08",
|
||||
"parent": "my_station",
|
||||
"config": { "port": "serial_pump", "address": "2", "max_volume": 25.0 }
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"source": "pump_1",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": { "pump_1": "port", "serial_pump": "port" }
|
||||
},
|
||||
{
|
||||
"source": "pump_2",
|
||||
"target": "serial_pump",
|
||||
"type": "communication",
|
||||
"port": { "pump_2": "port", "serial_pump": "port" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 通信协议速查
|
||||
|
||||
| 协议 | config 参数 | 依赖包 | 通信设备 class |
|
||||
| -------------------- | ------------------------------ | ---------- | -------------------------- |
|
||||
| Serial (RS232/RS485) | `port`, `baudrate` | `pyserial` | `serial` |
|
||||
| Modbus RTU | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
|
||||
| Modbus TCP | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
|
||||
| TCP Socket | `host`, `port` | stdlib | 自定义 |
|
||||
| HTTP API | `url`, `token` | `requests` | `device_comms/rpc.py` |
|
||||
|
||||
参考实现:`unilabos/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json`
|
||||
|
||||
---
|
||||
|
||||
## Deck 与物料生命周期
|
||||
|
||||
### 1. Deck 入参与两种初始化模式
|
||||
|
||||
系统根据设备节点 `config.deck` 的写法,自动反序列化 Deck 实例后传入 `__init__` 的 `deck` 参数。目前 `deck` 是固定字段名,只支持一个主 Deck。建议一个设备拥有一个台面,台面上抽象二级、三级子物料。
|
||||
|
||||
有两种初始化模式:
|
||||
|
||||
#### init 初始化(推荐)
|
||||
|
||||
`config.deck` 直接包含 `_resource_type` + `_resource_child_name`,系统先用 Deck 节点的 `config` 调用 Deck 类的 `__init__` 反序列化,再将实例传入设备的 `deck` 参数。子物料随 Deck 的 `children` 一起反序列化。
|
||||
|
||||
```json
|
||||
"config": {
|
||||
"deck": {
|
||||
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
|
||||
"_resource_child_name": "PRCXI_Deck"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### deserialize 初始化
|
||||
|
||||
`config.deck` 用 `data` 包裹一层,系统走 `deserialize` 路径,可传入更多参数(如 `allow_marshal` 等):
|
||||
|
||||
```json
|
||||
"config": {
|
||||
"deck": {
|
||||
"data": {
|
||||
"_resource_child_name": "YB_Bioyond_Deck",
|
||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
没有特殊需求时推荐 init 初始化。
|
||||
|
||||
#### config.deck 字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `_resource_type` | Deck 类的完整模块路径(`module:ClassName`) |
|
||||
| `_resource_child_name` | 对应图文件中 Deck 节点的 `id`,建立父子关联 |
|
||||
|
||||
#### 设备 __init__ 接收
|
||||
|
||||
```python
|
||||
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
# deck 已经是反序列化后的 Deck 实例
|
||||
# → PRCXI9300Deck / BIOYOND_YB_Deck 等
|
||||
```
|
||||
|
||||
#### Deck 节点(图文件中)
|
||||
|
||||
Deck 节点作为设备的 `children` 之一,`parent` 指向设备 id:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "PRCXI_Deck",
|
||||
"parent": "PRCXI",
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"children": [],
|
||||
"config": {
|
||||
"type": "PRCXI9300Deck",
|
||||
"size_x": 542, "size_y": 374, "size_z": 0,
|
||||
"category": "deck",
|
||||
"sites": [...]
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
- `config` 中的字段会传入 Deck 类的 `__init__`(因此 `__init__` 必须能接受所有 `serialize()` 输出的字段)
|
||||
- `children` 初始为空时,由同步器或手动初始化填充
|
||||
- `config.type` 填 Deck 类名
|
||||
|
||||
### 2. Deck 为空时自行初始化
|
||||
|
||||
如果 Deck 节点的 `children` 为空,工作站需在 `post_init` 或首次同步时自行初始化内容:
|
||||
|
||||
```python
|
||||
@not_action
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
if self.deck and not self.deck.children:
|
||||
self._initialize_default_deck()
|
||||
|
||||
def _initialize_default_deck(self):
|
||||
from my_labware import My_TipRack, My_Plate
|
||||
self.deck.assign_child_resource(My_TipRack("T1"), spot=0)
|
||||
self.deck.assign_child_resource(My_Plate("T2"), spot=1)
|
||||
```
|
||||
|
||||
### 3. 物料双向同步
|
||||
|
||||
当工作站对接外部系统(LIMS/MES)时,需要实现 `ResourceSynchronizer` 处理双向物料同步:
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
||||
|
||||
class MyResourceSynchronizer(ResourceSynchronizer):
|
||||
def sync_from_external(self) -> bool:
|
||||
"""从外部系统同步到 self.workstation.deck"""
|
||||
external_data = self._query_external_materials()
|
||||
# 以外部工站为准:根据外部数据反向创建 PLR 资源实例
|
||||
for item in external_data:
|
||||
cls = self._resolve_resource_class(item["type"])
|
||||
resource = cls(name=item["name"], **item["params"])
|
||||
self.workstation.deck.assign_child_resource(resource, spot=item["slot"])
|
||||
return True
|
||||
|
||||
def sync_to_external(self, resource) -> bool:
|
||||
"""将 UniLab 侧物料变更同步到外部系统"""
|
||||
# 以 UniLab 为准:将 PLR 资源转为外部格式并推送
|
||||
external_format = self._convert_to_external(resource)
|
||||
return self._push_to_external(external_format)
|
||||
|
||||
def handle_external_change(self, change_info) -> bool:
|
||||
"""处理外部系统主动推送的变更"""
|
||||
return True
|
||||
```
|
||||
|
||||
同步策略取决于业务场景:
|
||||
|
||||
- **以外部工站为准**:从外部 API 查询物料数据,反向创建对应的 PLR 资源实例放到 Deck 上
|
||||
- **以 UniLab 为准**:UniLab 侧的物料变更通过 `sync_to_external` 推送到外部系统
|
||||
|
||||
在工作站 `post_init` 中初始化同步器:
|
||||
|
||||
```python
|
||||
@not_action
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
self.resource_synchronizer = MyResourceSynchronizer(self)
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
```
|
||||
|
||||
### 4. 序列化与持久化(serialize / serialize_state)
|
||||
|
||||
资源类需正确实现序列化,系统据此完成持久化和前端同步。
|
||||
|
||||
**`serialize()`** — 输出资源的结构信息(`config` 层),反序列化时作为 `__init__` 的入参回传。因此 **`__init__` 必须通过 `**kwargs`接受`serialize()` 输出的所有字段\*\*,即使当前不使用:
|
||||
|
||||
```python
|
||||
class MyDeck(Deck):
|
||||
def __init__(self, name, size_x, size_y, size_z,
|
||||
sites=None, # serialize() 输出的字段
|
||||
rotation=None, # serialize() 输出的字段
|
||||
barcode=None, # serialize() 输出的字段
|
||||
**kwargs): # 兜底:接受所有未知的 serialize 字段
|
||||
super().__init__(size_x, size_y, size_z, name)
|
||||
# ...
|
||||
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
data["sites"] = [...] # 自定义字段
|
||||
return data
|
||||
```
|
||||
|
||||
**`serialize_state()`** — 输出资源的运行时状态(`data` 层),用于持久化可变信息。`data` 中的内容会被正确保存和恢复:
|
||||
|
||||
```python
|
||||
class MyPlate(Plate):
|
||||
def __init__(self, name, size_x, size_y, size_z,
|
||||
material_info=None, **kwargs):
|
||||
super().__init__(name, size_x, size_y, size_z, **kwargs)
|
||||
self._unilabos_state = {}
|
||||
if material_info:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
def serialize_state(self) -> Dict[str, Any]:
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state)
|
||||
return data
|
||||
```
|
||||
|
||||
关键要点:
|
||||
|
||||
- `serialize()` 输出的所有字段都会作为 `config` 回传到 `__init__`,所以 `__init__` 必须能接受它们(显式声明或 `**kwargs`)
|
||||
- `serialize_state()` 输出的 `data` 用于持久化运行时状态(如物料信息、液体量等)
|
||||
- `_unilabos_state` 中只存可 JSON 序列化的基本类型(str, int, float, bool, list, dict, None)
|
||||
|
||||
### 5. 子物料自动同步
|
||||
|
||||
子物料(Bottle、Plate、TipRack 等)放到 Deck 上后,系统会自动将其同步到前端的 Deck 视图。只需保证资源类正确实现了 `serialize()` / `serialize_state()` 和反序列化即可。
|
||||
|
||||
### 6. 图文件配置(参考 prcxi_9320_slim.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "my_station",
|
||||
"type": "device",
|
||||
"class": "my_workstation",
|
||||
"config": {
|
||||
"deck": {
|
||||
"_resource_type": "unilabos.resources.my_module:MyDeck",
|
||||
"_resource_child_name": "my_deck"
|
||||
},
|
||||
"host": "10.20.30.1",
|
||||
"port": 9999
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "my_deck",
|
||||
"parent": "my_station",
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"children": [],
|
||||
"config": {
|
||||
"type": "MyLabDeck",
|
||||
"size_x": 542,
|
||||
"size_y": 374,
|
||||
"size_z": 0,
|
||||
"category": "deck",
|
||||
"sites": [
|
||||
{
|
||||
"label": "T1",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": { "x": 0, "y": 0, "z": 0 },
|
||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||
"content_type": ["plate", "tip_rack", "tube_rack", "adaptor"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
```
|
||||
|
||||
Deck 节点要点:
|
||||
|
||||
- `config.type` 填 Deck 类名(如 `"PRCXI9300Deck"`)
|
||||
- `config.sites` 完整列出所有 site(从 Deck 类的 `serialize()` 输出获取)
|
||||
- `children` 初始为空(由同步器或手动初始化填充)
|
||||
- 设备节点 `config.deck._resource_type` 指向 Deck 类的完整模块路径
|
||||
|
||||
---
|
||||
|
||||
## 子设备
|
||||
|
||||
子设备按标准设备接入流程创建(参见 add-device SKILL),使用 `@device` 装饰器。
|
||||
|
||||
子设备约束:
|
||||
|
||||
- 图文件中 `parent` 指向工作站 ID
|
||||
- 在工作站 `children` 数组中列出
|
||||
|
||||
---
|
||||
|
||||
## 关键规则
|
||||
|
||||
1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.**init**`需要`deck` 参数
|
||||
2. **Deck 通过 `config.deck._resource_type` 反序列化传入** — 不要在 `__init__` 中手动创建 Deck
|
||||
3. **Deck 为空时自行初始化内容** — 在 `post_init` 中检查并填充默认物料
|
||||
4. **外部同步实现 `ResourceSynchronizer`** — `sync_from_external` / `sync_to_external`
|
||||
5. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用
|
||||
6. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接
|
||||
7. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()`
|
||||
8. **使用 `@not_action` 标记非动作方法** — `post_init`, `initialize`, `cleanup`
|
||||
9. **子物料保证正确 serialize/deserialize** — 系统自动同步到前端 Deck 视图
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 模块可导入
|
||||
python -c "from unilabos.devices.workstation.<name>.<name> import <ClassName>"
|
||||
|
||||
# 启动测试(AST 自动扫描)
|
||||
unilab -g <graph>.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 现有工作站参考
|
||||
|
||||
| 工作站 | 驱动类 | 类型 |
|
||||
| -------------- | ----------------------------- | -------- |
|
||||
| Protocol 通用 | `ProtocolNode` | Protocol |
|
||||
| Bioyond 反应站 | `BioyondReactionStation` | 外部系统 |
|
||||
| 纽扣电池组装 | `CoinCellAssemblyWorkstation` | 硬件控制 |
|
||||
|
||||
参考路径:`unilabos/devices/workstation/` 目录下各工作站实现。
|
||||
371
.cursor/skills/add-workstation/reference.md
Normal file
371
.cursor/skills/add-workstation/reference.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# 工作站高级模式参考
|
||||
|
||||
本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。
|
||||
Agent 在需要实现这些功能时按需阅读。
|
||||
|
||||
---
|
||||
|
||||
## 1. 外部系统集成模式
|
||||
|
||||
### 1.1 RPC 客户端
|
||||
|
||||
与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。
|
||||
|
||||
```python
|
||||
from unilabos.device_comms.rpc import BaseRequest
|
||||
|
||||
|
||||
class MySystemRPC(BaseRequest):
|
||||
"""外部系统 RPC 客户端"""
|
||||
|
||||
def __init__(self, host: str, api_key: str):
|
||||
super().__init__(host)
|
||||
self.api_key = api_key
|
||||
|
||||
def _request(self, endpoint: str, data: dict = None) -> dict:
|
||||
return self.post(
|
||||
url=f"{self.host}/api/{endpoint}",
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
"data": data or {},
|
||||
},
|
||||
)
|
||||
|
||||
def query_status(self) -> dict:
|
||||
return self._request("status/query")
|
||||
|
||||
def create_order(self, order_data: dict) -> dict:
|
||||
return self._request("order/create", order_data)
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`(`BioyondV1RPC`)
|
||||
|
||||
### 1.2 HTTP 回调服务
|
||||
|
||||
接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
|
||||
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def __init__(self, config=None, deck=None, **kwargs):
|
||||
super().__init__(deck=deck, **kwargs)
|
||||
self.config = config or {}
|
||||
http_cfg = self.config.get("http_service_config", {})
|
||||
self._http_service_config = {
|
||||
"host": http_cfg.get("http_service_host", "127.0.0.1"),
|
||||
"port": http_cfg.get("http_service_port", 8080),
|
||||
}
|
||||
self.http_service = None
|
||||
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
host=self._http_service_config["host"],
|
||||
port=self._http_service_config["port"],
|
||||
)
|
||||
self.http_service.start()
|
||||
```
|
||||
|
||||
**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发):
|
||||
|
||||
| 端点 | 调用的工作站方法 |
|
||||
|------|-----------------|
|
||||
| `/report/step_finish` | `process_step_finish_report(report_request)` |
|
||||
| `/report/sample_finish` | `process_sample_finish_report(report_request)` |
|
||||
| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` |
|
||||
| `/report/material_change` | `process_material_change_report(report_data)` |
|
||||
| `/report/error_handling` | `handle_external_error(error_data)` |
|
||||
|
||||
实现对应方法即可接收回调:
|
||||
|
||||
```python
|
||||
def process_step_finish_report(self, report_request) -> Dict[str, Any]:
|
||||
"""处理步骤完成报告"""
|
||||
step_name = report_request.data.get("stepName")
|
||||
return {"success": True, "message": f"步骤 {step_name} 已处理"}
|
||||
|
||||
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
|
||||
"""处理订单完成报告"""
|
||||
order_code = report_request.data.get("orderCode")
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/workstation_http_service.py`
|
||||
|
||||
### 1.3 连接监控
|
||||
|
||||
独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。
|
||||
|
||||
```python
|
||||
class ConnectionMonitor:
|
||||
def __init__(self, workstation, check_interval=30):
|
||||
self.workstation = workstation
|
||||
self.check_interval = check_interval
|
||||
self._running = False
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _monitor_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
# 调用外部系统接口检测连接
|
||||
self.workstation.hardware_interface.ping()
|
||||
status = "online"
|
||||
except Exception:
|
||||
status = "offline"
|
||||
time.sleep(self.check_interval)
|
||||
```
|
||||
|
||||
参考:`unilabos/devices/workstation/bioyond_studio/station.py`(`ConnectionMonitor`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Config 结构模式
|
||||
|
||||
工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式:
|
||||
|
||||
### 2.1 外部系统连接
|
||||
|
||||
```json
|
||||
{
|
||||
"api_host": "http://192.168.1.100:8080",
|
||||
"api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 HTTP 回调服务
|
||||
|
||||
```json
|
||||
{
|
||||
"http_service_config": {
|
||||
"http_service_host": "127.0.0.1",
|
||||
"http_service_port": 8080
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 物料类型映射
|
||||
|
||||
将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID)。用于双向物料转换。
|
||||
|
||||
```json
|
||||
{
|
||||
"material_type_mappings": {
|
||||
"PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"],
|
||||
"BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 仓库映射
|
||||
|
||||
将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。
|
||||
|
||||
```json
|
||||
{
|
||||
"warehouse_mapping": {
|
||||
"仓库名": {
|
||||
"uuid": "warehouse-uuid",
|
||||
"site_uuids": {
|
||||
"A01": "site-uuid-A01",
|
||||
"A02": "site-uuid-A02"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 工作流映射
|
||||
|
||||
将内部工作流名映射到外部系统的工作流 ID。
|
||||
|
||||
```json
|
||||
{
|
||||
"workflow_mappings": {
|
||||
"internal_workflow_name": "external-workflow-uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 物料默认参数
|
||||
|
||||
```json
|
||||
{
|
||||
"material_default_parameters": {
|
||||
"NMP": {
|
||||
"unit": "毫升",
|
||||
"density": "1.03",
|
||||
"densityUnit": "g/mL",
|
||||
"description": "N-甲基吡咯烷酮"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 资源同步机制
|
||||
|
||||
### 3.1 ResourceSynchronizer
|
||||
|
||||
抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`。
|
||||
|
||||
```python
|
||||
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
||||
|
||||
|
||||
class MyResourceSynchronizer(ResourceSynchronizer):
|
||||
def __init__(self, workstation, api_client):
|
||||
super().__init__(workstation)
|
||||
self.api_client = api_client
|
||||
|
||||
def sync_from_external(self) -> bool:
|
||||
"""从外部系统拉取物料到 deck"""
|
||||
external_materials = self.api_client.list_materials()
|
||||
for material in external_materials:
|
||||
plr_resource = self._convert_to_plr(material)
|
||||
self.workstation.deck.assign_child_resource(plr_resource, coordinate)
|
||||
return True
|
||||
|
||||
def sync_to_external(self, plr_resource) -> bool:
|
||||
"""将 deck 中的物料变更推送到外部系统"""
|
||||
external_data = self._convert_from_plr(plr_resource)
|
||||
self.api_client.update_material(external_data)
|
||||
return True
|
||||
|
||||
def handle_external_change(self, change_info) -> bool:
|
||||
"""处理外部系统推送的物料变更"""
|
||||
return True
|
||||
```
|
||||
|
||||
### 3.2 update_resource — 上传资源树到云端
|
||||
|
||||
将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景:
|
||||
|
||||
```python
|
||||
# 在 post_init 中上传初始 deck
|
||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
|
||||
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [self.deck]}
|
||||
)
|
||||
|
||||
# 在动作方法中更新特定资源
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [updated_plate]}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 工作流序列管理
|
||||
|
||||
工作站通过 `workflow_sequence` 属性管理任务队列(JSON 字符串形式)。
|
||||
|
||||
```python
|
||||
class MyWorkstation(WorkstationBase):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._workflow_sequence = []
|
||||
|
||||
@property
|
||||
def workflow_sequence(self) -> str:
|
||||
"""返回 JSON 字符串,ROS 自动发布"""
|
||||
import json
|
||||
return json.dumps(self._workflow_sequence)
|
||||
|
||||
async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]:
|
||||
"""添加工作流到队列"""
|
||||
self._workflow_sequence.append({
|
||||
"name": workflow_name,
|
||||
"status": "pending",
|
||||
"created_at": time.time(),
|
||||
})
|
||||
return {"success": True}
|
||||
|
||||
async def clear_workflows(self) -> Dict[str, Any]:
|
||||
"""清空工作流队列"""
|
||||
self._workflow_sequence = []
|
||||
return {"success": True}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 站间物料转移
|
||||
|
||||
工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。
|
||||
|
||||
```python
|
||||
async def transfer_materials_to_another_station(
|
||||
self,
|
||||
target_device_id: str,
|
||||
transfer_groups: list,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""将物料转移到另一个工作站"""
|
||||
target_node = self._children.get(target_device_id)
|
||||
if not target_node:
|
||||
# 通过 ROS 节点查找非子设备的目标站
|
||||
pass
|
||||
|
||||
for group in transfer_groups:
|
||||
resource = self.find_resource_by_name(group["resource_name"])
|
||||
# 从本站 deck 移除
|
||||
resource.unassign()
|
||||
# 调用目标站的接收方法
|
||||
# ...
|
||||
|
||||
return {"success": True, "transferred": len(transfer_groups)}
|
||||
```
|
||||
|
||||
参考:`BioyondDispensingStation.transfer_materials_to_reaction_station`
|
||||
|
||||
---
|
||||
|
||||
## 6. post_init 完整模式
|
||||
|
||||
`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。
|
||||
|
||||
```python
|
||||
def post_init(self, ros_node):
|
||||
super().post_init(ros_node)
|
||||
|
||||
# 1. 初始化外部系统客户端(此时 config 已可用)
|
||||
self.rpc_client = MySystemRPC(
|
||||
host=self.config.get("api_host"),
|
||||
api_key=self.config.get("api_key"),
|
||||
)
|
||||
self.hardware_interface = self.rpc_client
|
||||
|
||||
# 2. 启动连接监控
|
||||
self.connection_monitor = ConnectionMonitor(self)
|
||||
self.connection_monitor.start()
|
||||
|
||||
# 3. 启动 HTTP 回调服务
|
||||
if hasattr(self, '_http_service_config'):
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
host=self._http_service_config["host"],
|
||||
port=self._http_service_config["port"],
|
||||
)
|
||||
self.http_service.start()
|
||||
|
||||
# 4. 上传 deck 到云端
|
||||
ROS2DeviceNode.run_async_func(
|
||||
self._ros_node.update_resource, True,
|
||||
**{"resources": [self.deck]}
|
||||
)
|
||||
|
||||
# 5. 初始化资源同步器(可选)
|
||||
self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client)
|
||||
```
|
||||
261
.cursor/skills/batch-insert-reagent/SKILL.md
Normal file
261
.cursor/skills/batch-insert-reagent/SKILL.md
Normal file
@@ -0,0 +1,261 @@
|
||||
---
|
||||
name: batch-insert-reagent
|
||||
description: Batch insert reagents into Uni-Lab platform — add chemicals with CAS, SMILES, supplier info. Use when the user wants to add reagents, insert chemicals, batch register reagents, or mentions 录入试剂/添加试剂/试剂入库/reagent.
|
||||
---
|
||||
|
||||
# 批量录入试剂 Skill
|
||||
|
||||
通过云端 API 批量录入试剂信息,支持逐条或批量操作。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
||||
|
||||
生成 AUTH token(任选一种方式):
|
||||
|
||||
```bash
|
||||
# 方式一:Python 一行生成
|
||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
|
||||
# 方式二:手动计算
|
||||
# base64(ak:sk) → Authorization: Lab <token>
|
||||
```
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
|
||||
```
|
||||
|
||||
**两项全部就绪后才可发起 API 请求。**
|
||||
|
||||
## Session State
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
|
||||
### 2. 录入试剂
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"lab_uuid": "<lab_uuid>",
|
||||
"cas": "<CAS号>",
|
||||
"name": "<试剂名称>",
|
||||
"molecular_formula": "<分子式>",
|
||||
"smiles": "<SMILES>",
|
||||
"stock_in_quantity": <入库数量>,
|
||||
"unit": "<单位字符串>",
|
||||
"supplier": "<供应商>",
|
||||
"production_date": "<生产日期 ISO 8601>",
|
||||
"expiry_date": "<过期日期 ISO 8601>"
|
||||
}'
|
||||
```
|
||||
|
||||
返回成功时包含试剂 UUID:
|
||||
|
||||
```json
|
||||
{"code": 0, "data": {"uuid": "xxx", ...}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 试剂字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
||||
| ------------------- | ------ | ---- | ----------------------------- | ------------------------ |
|
||||
| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` |
|
||||
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
|
||||
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
|
||||
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
|
||||
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
|
||||
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
|
||||
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
|
||||
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
|
||||
| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` |
|
||||
| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` |
|
||||
|
||||
### unit 单位值
|
||||
|
||||
| 值 | 单位 |
|
||||
| ------ | ---- |
|
||||
| `"mL"` | 毫升 |
|
||||
| `"L"` | 升 |
|
||||
| `"g"` | 克 |
|
||||
| `"kg"` | 千克 |
|
||||
| `"瓶"` | 瓶 |
|
||||
|
||||
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
|
||||
|
||||
---
|
||||
|
||||
## 批量录入策略
|
||||
|
||||
### 方式一:用户提供 JSON 数组
|
||||
|
||||
用户一次性给出多条试剂数据:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"cas": "7732-18-3",
|
||||
"name": "水",
|
||||
"molecular_formula": "H2O",
|
||||
"smiles": "O",
|
||||
"stock_in_quantity": 10,
|
||||
"unit": "mL"
|
||||
},
|
||||
{
|
||||
"cas": "64-17-5",
|
||||
"name": "乙醇",
|
||||
"molecular_formula": "C2H6O",
|
||||
"smiles": "CCO",
|
||||
"stock_in_quantity": 5,
|
||||
"unit": "L"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Agent 自动为每条补充 `lab_uuid`、`production_date`、`expiry_date` 等字段后逐条提交。
|
||||
|
||||
Agent 循环调用 API #2 逐条录入,每条记录一次 API 调用。
|
||||
|
||||
### 方式二:用户逐个描述
|
||||
|
||||
用户口头描述试剂(如「帮我录入 500mL 的无水乙醇,Sigma 的」),agent 自行补全字段:
|
||||
|
||||
1. 根据名称查找 CAS 号、分子式、SMILES(参考下方速查表或自行推断)
|
||||
2. 构建完整的请求体
|
||||
3. 向用户确认后提交
|
||||
|
||||
### 方式三:从 CSV/表格批量导入
|
||||
|
||||
用户提供 CSV 或表格文件路径,agent 读取并解析:
|
||||
|
||||
```bash
|
||||
# 期望的 CSV 格式(首行为表头)
|
||||
cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_date,expiry_date
|
||||
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
|
||||
```
|
||||
|
||||
### 日期格式规则(重要)
|
||||
|
||||
所有日期字段(`production_date`、`expiry_date`)**必须**使用 ISO 8601 完整格式:`YYYY-MM-DDTHH:MM:SSZ`。
|
||||
|
||||
- 用户输入 `2025-03-01` → 转换为 `"2025-03-01T00:00:00Z"`
|
||||
- 用户输入 `2025/9/1` → 转换为 `"2025-09-01T00:00:00Z"`
|
||||
- 用户未提供日期 → 使用当天日期 + `T00:00:00Z`,有效期默认 +1 年
|
||||
|
||||
**禁止**发送不带时间部分的日期字符串(如 `"2025-03-01"`),API 会拒绝。
|
||||
|
||||
### 执行与汇报
|
||||
|
||||
每次 API 调用后:
|
||||
|
||||
1. 检查返回 `code`(0 = 成功)
|
||||
2. 记录成功/失败数量
|
||||
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
|
||||
4. 如有失败,列出失败的试剂名称和错误信息
|
||||
|
||||
---
|
||||
|
||||
## 常见试剂速查表
|
||||
|
||||
| 名称 | CAS | 分子式 | SMILES |
|
||||
| --------------------- | --------- | ---------- | ------------------------------------ |
|
||||
| 水 | 7732-18-3 | H2O | O |
|
||||
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
||||
| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O |
|
||||
| 甲醇 | 67-56-1 | CH4O | CO |
|
||||
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
|
||||
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
|
||||
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
|
||||
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
|
||||
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
|
||||
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
|
||||
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
|
||||
| 乙腈 | 75-05-8 | C2H3N | CC#N |
|
||||
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
|
||||
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
|
||||
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
|
||||
| 盐酸 | 7647-01-0 | HCl | Cl |
|
||||
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
|
||||
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
|
||||
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
|
||||
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
|
||||
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
|
||||
|
||||
> 此表仅供快速参考。对于不在表中的试剂,agent 应根据化学知识推断或提示用户补充。
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||
- [ ] Step 4: 收集试剂信息(用户提供列表/逐个描述/CSV文件)
|
||||
- [ ] Step 5: 补全缺失字段(CAS、分子式、SMILES 等)
|
||||
- [ ] Step 6: 向用户确认待录入的试剂列表
|
||||
- [ ] Step 7: 循环调用 POST /lab/reagent 逐条录入(每条需含 lab_uuid)
|
||||
- [ ] Step 8: 汇总结果(成功/失败数量及详情)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整示例
|
||||
|
||||
用户说:「帮我录入 3 种试剂:500mL 无水乙醇、1kg 氯化钠、2L 去离子水」
|
||||
|
||||
Agent 构建的请求序列:
|
||||
|
||||
```json
|
||||
// 第 1 条
|
||||
{"lab_uuid": "8511c672-...", "cas": "64-17-5", "name": "无水乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 500, "unit": "mL", "supplier": "国药集团", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
||||
|
||||
// 第 2 条
|
||||
{"lab_uuid": "8511c672-...", "cas": "7647-14-5", "name": "氯化钠", "molecular_formula": "NaCl", "smiles": "[Na]Cl", "stock_in_quantity": 1, "unit": "kg", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
||||
|
||||
// 第 3 条
|
||||
{"lab_uuid": "8511c672-...", "cas": "7732-18-3", "name": "去离子水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 2, "unit": "L", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
||||
```
|
||||
360
.cursor/skills/batch-submit-experiment/SKILL.md
Normal file
360
.cursor/skills/batch-submit-experiment/SKILL.md
Normal file
@@ -0,0 +1,360 @@
|
||||
---
|
||||
name: batch-submit-experiment
|
||||
description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform (leap-lab) — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
|
||||
---
|
||||
|
||||
# Uni-Lab 批量提交实验指南
|
||||
|
||||
通过 Uni-Lab 云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
||||
|
||||
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
||||
|
||||
生成 AUTH token(任选一种方式):
|
||||
|
||||
```bash
|
||||
# 方式一:Python 一行生成(注意:scheme 是 "Lab" 不是 "Basic")
|
||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
|
||||
# 方式二:手动计算
|
||||
# base64(ak:sk) → Authorization: Lab <token>
|
||||
# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme,绝对不能用 "Basic" 替代
|
||||
```
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
```
|
||||
|
||||
### 3. req_device_registry_upload.json(设备注册表)
|
||||
|
||||
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
|
||||
|
||||
**必须先用 Glob 工具搜索文件**,不要直接猜测路径:
|
||||
|
||||
```
|
||||
Glob: **/req_device_registry_upload.json
|
||||
```
|
||||
|
||||
常见位置(仅供参考,以 Glob 实际结果为准):
|
||||
- `<workspace>/unilabos_data/req_device_registry_upload.json`
|
||||
- `<workspace>/req_device_registry_upload.json`
|
||||
|
||||
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。
|
||||
|
||||
**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
||||
|
||||
### 4. workflow_uuid(目标工作流)
|
||||
|
||||
用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #3 列出可用 workflow 供选择。
|
||||
|
||||
**四项全部就绪后才可开始。**
|
||||
|
||||
## Session State
|
||||
|
||||
在整个对话过程中,agent 需要记住以下状态,避免重复询问用户:
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||
- `project_uuid` — 项目 UUID(通过 API #2 列出项目列表,**让用户选择**)
|
||||
- `workflow_uuid` — 工作流 UUID(用户提供或从列表选择)
|
||||
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #4 获取)
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
||||
>
|
||||
> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则会被解析为 splatting 运算符)。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
|
||||
### 2. 列出实验室项目(让用户选择项目)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"uuid": "1b3f249a-...",
|
||||
"name": "bt",
|
||||
"description": null,
|
||||
"status": "active",
|
||||
"created_at": "2026-04-09T14:31:28+08:00"
|
||||
},
|
||||
{
|
||||
"uuid": "b6366243-...",
|
||||
"name": "default",
|
||||
"description": "默认项目",
|
||||
"status": "active",
|
||||
"created_at": "2026-03-26T11:13:36+08:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
展示 `data.items[]` 中每个项目的 `name` 和 `uuid`,让用户选择。用户**必须**选择一个项目,记住 `project_uuid`(即选中项目的 `uuid`),后续创建 notebook 时需要提供。
|
||||
|
||||
### 3. 列出可用 workflow
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid` 和 `name`。
|
||||
|
||||
### 4. 获取 workflow 模板详情
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
|
||||
|
||||
- 每个 action 节点的 `node_uuid`
|
||||
- 每个节点对应的设备 ID(`resource_template_name`)
|
||||
- 每个节点的动作名(`node_template_name`)
|
||||
- 每个节点的现有参数(`param`)
|
||||
|
||||
> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。
|
||||
|
||||
### 5. 提交实验(创建 notebook)
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/notebook" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"lab_uuid": "<lab_uuid>",
|
||||
"project_uuid": "<project_uuid>",
|
||||
"workflow_uuid": "<workflow_uuid>",
|
||||
"name": "<实验名称>",
|
||||
"node_params": [
|
||||
{
|
||||
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
|
||||
"datas": [
|
||||
{
|
||||
"node_uuid": "<workflow中的节点UUID>",
|
||||
"param": {},
|
||||
"sample_params": [
|
||||
{
|
||||
"container_uuid": "<容器UUID>",
|
||||
"sample_value": {
|
||||
"liquid_names": "<液体名称>",
|
||||
"volumes": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`sample_uuids` 必须是 **UUID 数组**(`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。
|
||||
|
||||
### 6. 查询 notebook 状态
|
||||
|
||||
提交成功后,使用返回的 notebook UUID 查询执行状态:
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
提交后应**立即查询一次**状态,确认 notebook 已被正确接收并开始调度。
|
||||
|
||||
---
|
||||
|
||||
## Notebook 请求体详解
|
||||
|
||||
### node_params 结构
|
||||
|
||||
`node_params` 是一个数组,**每个元素代表一轮实验**:
|
||||
|
||||
- 要跑 2 轮 → `node_params` 有 2 个元素
|
||||
- 要跑 N 轮 → `node_params` 有 N 个元素
|
||||
|
||||
### 每轮的字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| -------------- | ------------- | ----------------------------------------- |
|
||||
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
|
||||
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
||||
|
||||
### datas 中每个节点
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------------- | ------ | -------------------------------------------- |
|
||||
| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) |
|
||||
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
|
||||
| `sample_params` | array | 样品相关参数(液体名、体积等) |
|
||||
|
||||
### sample_params 中每条
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ---------------- | ------ | ---------------------------------------------------- |
|
||||
| `container_uuid` | string | 容器 UUID |
|
||||
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
|
||||
|
||||
---
|
||||
|
||||
## 从本地注册表生成 param 模板
|
||||
|
||||
### 自动方式 — 运行脚本
|
||||
|
||||
```bash
|
||||
python scripts/gen_notebook_params.py \
|
||||
--auth <token> \
|
||||
--base <BASE_URL> \
|
||||
--workflow-uuid <workflow_uuid> \
|
||||
[--registry <path/to/req_device_registry_upload.json>] \
|
||||
[--rounds <轮次数>] \
|
||||
[--output <输出文件路径>]
|
||||
```
|
||||
|
||||
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
|
||||
|
||||
脚本会:
|
||||
|
||||
1. 调用 workflow detail API 获取所有 action 节点
|
||||
2. 读取本地注册表,为每个节点查找对应的 action schema
|
||||
3. 生成 `notebook_template.json`,包含:
|
||||
- 完整 `node_params` 骨架
|
||||
- 每个节点的 param 字段及类型说明
|
||||
- `_schema_info` 辅助信息(不提交,仅供参考)
|
||||
|
||||
### 手动方式
|
||||
|
||||
如果脚本不可用或注册表不存在:
|
||||
|
||||
1. 调用 API #4 获取 workflow 详情
|
||||
2. 找到每个 action 节点的 `node_uuid`
|
||||
3. 在本地注册表中查找对应设备的 `action_value_mappings`:
|
||||
```
|
||||
resources[].id == <device_id>
|
||||
→ resources[].class.action_value_mappings.<action_name>.schema.properties.goal.properties
|
||||
```
|
||||
4. 将 schema 中的 properties 作为 `param` 的字段模板
|
||||
5. 按轮次复制 `node_params` 元素,让用户填写每轮的具体值
|
||||
|
||||
### 注册表结构参考
|
||||
|
||||
```json
|
||||
{
|
||||
"resources": [
|
||||
{
|
||||
"id": "liquid_handler.prcxi",
|
||||
"class": {
|
||||
"module": "unilabos.devices.xxx:ClassName",
|
||||
"action_value_mappings": {
|
||||
"transfer_liquid": {
|
||||
"type": "LiquidHandlerTransfer",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"goal": {
|
||||
"properties": {
|
||||
"asp_vols": {
|
||||
"type": "array",
|
||||
"items": { "type": "number" }
|
||||
},
|
||||
"sources": { "type": "array" }
|
||||
},
|
||||
"required": ["asp_vols", "sources"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"goal_default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`param` 填写时,使用 `goal.properties` 中的字段名和类型。
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||
- [ ] Step 4: GET /lab/project/list → 列出项目,让用户选择 → 获取 project_uuid
|
||||
- [ ] Step 5: 确认 workflow_uuid(用户提供或从 GET #3 列表选择)
|
||||
- [ ] Step 6: GET workflow detail (#4) → 提取各节点 uuid、设备ID、动作名
|
||||
- [ ] Step 7: 定位本地注册表 req_device_registry_upload.json
|
||||
- [ ] Step 8: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
|
||||
- [ ] Step 9: 引导用户填写每轮的参数(sample_uuids、param、sample_params)
|
||||
- [ ] Step 10: 构建完整请求体(含 project_uuid)→ POST /lab/notebook 提交
|
||||
- [ ] Step 11: 检查返回结果,记录 notebook UUID
|
||||
- [ ] Step 12: GET /lab/notebook/status → 查询 notebook 状态,确认已调度
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: workflow 中有多个节点,每轮都要填所有节点的参数吗?
|
||||
|
||||
是的。`datas` 数组中需要包含该轮实验涉及的每个 workflow 节点的参数。通常每个 action 节点都需要一条 `datas` 记录。
|
||||
|
||||
### Q: 多轮实验的参数完全不同吗?
|
||||
|
||||
通常每轮的 `param`(设备动作参数)可能相同或相似,但 `sample_uuids` 和 `sample_params`(样品信息)每轮不同。脚本生成模板时会按轮次复制骨架,用户只需修改差异部分。
|
||||
|
||||
### Q: 如何获取 sample_uuids 和 container_uuid?
|
||||
|
||||
这些 UUID 通常来自实验室的样品管理系统。向用户询问,或从资源树(API `GET /lab/material/download/$lab_uuid`)中查找。
|
||||
@@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 workflow 模板详情 + 本地设备注册表生成 notebook 提交用的 node_params 模板。
|
||||
|
||||
用法:
|
||||
python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]
|
||||
|
||||
选项:
|
||||
--auth <token> Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀)
|
||||
--base <url> API 基础 URL(如 https://leap-lab.test.bohrium.com)
|
||||
--workflow-uuid <uuid> 目标 workflow 的 UUID
|
||||
--registry <path> 本地注册表文件路径(默认自动搜索)
|
||||
--rounds <n> 实验轮次数(默认 1)
|
||||
--output <path> 输出模板文件路径(默认 notebook_template.json)
|
||||
--dump-response 打印 workflow detail API 的原始响应(调试用)
|
||||
|
||||
示例:
|
||||
python gen_notebook_params.py \\
|
||||
--auth YTFmZDlkNGUtxxxx \\
|
||||
--base https://leap-lab.test.bohrium.com \\
|
||||
--workflow-uuid abc-123-def \\
|
||||
--rounds 2
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
REGISTRY_FILENAME = "req_device_registry_upload.json"
|
||||
|
||||
|
||||
def find_registry(explicit_path=None):
|
||||
"""查找本地注册表文件,逻辑同 extract_device_actions.py"""
|
||||
if explicit_path:
|
||||
if os.path.isfile(explicit_path):
|
||||
return explicit_path
|
||||
if os.path.isdir(explicit_path):
|
||||
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
|
||||
if os.path.isfile(fp):
|
||||
return fp
|
||||
print(f"警告: 指定的注册表路径不存在: {explicit_path}")
|
||||
return None
|
||||
|
||||
candidates = [
|
||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
||||
REGISTRY_FILENAME,
|
||||
]
|
||||
for c in candidates:
|
||||
if os.path.isfile(c):
|
||||
return c
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
|
||||
for c in candidates:
|
||||
path = os.path.join(workspace_root, c)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
cwd = os.getcwd()
|
||||
for _ in range(5):
|
||||
parent = os.path.dirname(cwd)
|
||||
if parent == cwd:
|
||||
break
|
||||
cwd = parent
|
||||
for c in candidates:
|
||||
path = os.path.join(cwd, c)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def load_registry(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def build_registry_index(registry_data):
|
||||
"""构建 device_id → action_value_mappings 的索引"""
|
||||
index = {}
|
||||
for res in registry_data.get("resources", []):
|
||||
rid = res.get("id", "")
|
||||
avm = res.get("class", {}).get("action_value_mappings", {})
|
||||
if rid and avm:
|
||||
index[rid] = avm
|
||||
return index
|
||||
|
||||
|
||||
def flatten_goal_schema(action_data):
|
||||
"""从 action_value_mappings 条目中提取 goal 层的 schema"""
|
||||
schema = action_data.get("schema", {})
|
||||
goal_schema = schema.get("properties", {}).get("goal", {})
|
||||
return goal_schema if goal_schema else schema
|
||||
|
||||
|
||||
def build_param_template(goal_schema):
|
||||
"""根据 goal schema 生成 param 模板,含类型标注"""
|
||||
properties = goal_schema.get("properties", {})
|
||||
required = set(goal_schema.get("required", []))
|
||||
template = {}
|
||||
for field_name, field_def in properties.items():
|
||||
if field_name == "unilabos_device_id":
|
||||
continue
|
||||
ftype = field_def.get("type", "any")
|
||||
default = field_def.get("default")
|
||||
if default is not None:
|
||||
template[field_name] = default
|
||||
elif ftype == "string":
|
||||
template[field_name] = f"$TODO ({ftype}, {'required' if field_name in required else 'optional'})"
|
||||
elif ftype == "number" or ftype == "integer":
|
||||
template[field_name] = 0
|
||||
elif ftype == "boolean":
|
||||
template[field_name] = False
|
||||
elif ftype == "array":
|
||||
template[field_name] = []
|
||||
elif ftype == "object":
|
||||
template[field_name] = {}
|
||||
else:
|
||||
template[field_name] = f"$TODO ({ftype})"
|
||||
return template
|
||||
|
||||
|
||||
def fetch_workflow_detail(base_url, auth_token, workflow_uuid):
|
||||
"""调用 workflow detail API"""
|
||||
url = f"{base_url}/api/v1/lab/workflow/template/detail/{workflow_uuid}"
|
||||
req = Request(url, method="GET")
|
||||
req.add_header("Authorization", f"Lab {auth_token}")
|
||||
try:
|
||||
with urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
print(f"API 错误 {e.code}: {body}")
|
||||
return None
|
||||
except URLError as e:
|
||||
print(f"网络错误: {e.reason}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_nodes_from_response(response):
|
||||
"""
|
||||
从 workflow detail 响应中提取 action 节点列表。
|
||||
适配多种可能的响应格式。
|
||||
|
||||
返回: [(node_uuid, resource_template_name, node_template_name, existing_param), ...]
|
||||
"""
|
||||
data = response.get("data", response)
|
||||
|
||||
search_keys = ["nodes", "workflow_nodes", "node_list", "steps"]
|
||||
nodes_raw = None
|
||||
for key in search_keys:
|
||||
if key in data and isinstance(data[key], list):
|
||||
nodes_raw = data[key]
|
||||
break
|
||||
|
||||
if nodes_raw is None:
|
||||
if isinstance(data, list):
|
||||
nodes_raw = data
|
||||
else:
|
||||
for v in data.values():
|
||||
if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
|
||||
nodes_raw = v
|
||||
break
|
||||
|
||||
if not nodes_raw:
|
||||
print("警告: 未能从响应中提取节点列表")
|
||||
print("响应顶层 keys:", list(data.keys()) if isinstance(data, dict) else type(data).__name__)
|
||||
return []
|
||||
|
||||
result = []
|
||||
for node in nodes_raw:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
node_uuid = (
|
||||
node.get("uuid")
|
||||
or node.get("node_uuid")
|
||||
or node.get("id")
|
||||
or ""
|
||||
)
|
||||
resource_name = (
|
||||
node.get("resource_template_name")
|
||||
or node.get("device_id")
|
||||
or node.get("resource_name")
|
||||
or node.get("device_name")
|
||||
or ""
|
||||
)
|
||||
template_name = (
|
||||
node.get("node_template_name")
|
||||
or node.get("action_name")
|
||||
or node.get("template_name")
|
||||
or node.get("action")
|
||||
or node.get("name")
|
||||
or ""
|
||||
)
|
||||
existing_param = node.get("param", {}) or {}
|
||||
|
||||
if node_uuid:
|
||||
result.append((node_uuid, resource_name, template_name, existing_param))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def generate_template(nodes, registry_index, rounds):
|
||||
"""生成 notebook 提交模板"""
|
||||
node_params = []
|
||||
schema_info = {}
|
||||
|
||||
datas_template = []
|
||||
for node_uuid, resource_name, template_name, existing_param in nodes:
|
||||
param_template = {}
|
||||
matched = False
|
||||
|
||||
if resource_name and template_name and resource_name in registry_index:
|
||||
avm = registry_index[resource_name]
|
||||
if template_name in avm:
|
||||
goal_schema = flatten_goal_schema(avm[template_name])
|
||||
param_template = build_param_template(goal_schema)
|
||||
goal_default = avm[template_name].get("goal_default", {})
|
||||
if goal_default:
|
||||
for k, v in goal_default.items():
|
||||
if k in param_template and v is not None:
|
||||
param_template[k] = v
|
||||
matched = True
|
||||
|
||||
schema_info[node_uuid] = {
|
||||
"device_id": resource_name,
|
||||
"action_name": template_name,
|
||||
"action_type": avm[template_name].get("type", ""),
|
||||
"schema_properties": list(goal_schema.get("properties", {}).keys()),
|
||||
"required": goal_schema.get("required", []),
|
||||
}
|
||||
|
||||
if not matched and existing_param:
|
||||
param_template = existing_param
|
||||
|
||||
if not matched and not existing_param:
|
||||
schema_info[node_uuid] = {
|
||||
"device_id": resource_name,
|
||||
"action_name": template_name,
|
||||
"warning": "未在本地注册表中找到匹配的 action schema",
|
||||
}
|
||||
|
||||
datas_template.append({
|
||||
"node_uuid": node_uuid,
|
||||
"param": param_template,
|
||||
"sample_params": [
|
||||
{
|
||||
"container_uuid": "$TODO_CONTAINER_UUID",
|
||||
"sample_value": {
|
||||
"liquid_names": "$TODO_LIQUID_NAME",
|
||||
"volumes": 0,
|
||||
},
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
for i in range(rounds):
|
||||
node_params.append({
|
||||
"sample_uuids": f"$TODO_SAMPLE_UUID_ROUND_{i + 1}",
|
||||
"datas": copy.deepcopy(datas_template),
|
||||
})
|
||||
|
||||
return {
|
||||
"lab_uuid": "$TODO_LAB_UUID",
|
||||
"project_uuid": "$TODO_PROJECT_UUID",
|
||||
"workflow_uuid": "$TODO_WORKFLOW_UUID",
|
||||
"name": "$TODO_EXPERIMENT_NAME",
|
||||
"node_params": node_params,
|
||||
"_schema_info(仅参考,提交时删除)": schema_info,
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
"""简单的参数解析"""
|
||||
opts = {
|
||||
"auth": None,
|
||||
"base": None,
|
||||
"workflow_uuid": None,
|
||||
"registry": None,
|
||||
"rounds": 1,
|
||||
"output": "notebook_template.json",
|
||||
"dump_response": False,
|
||||
}
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if arg == "--auth" and i + 1 < len(argv):
|
||||
opts["auth"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--base" and i + 1 < len(argv):
|
||||
opts["base"] = argv[i + 1].rstrip("/")
|
||||
i += 2
|
||||
elif arg == "--workflow-uuid" and i + 1 < len(argv):
|
||||
opts["workflow_uuid"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--registry" and i + 1 < len(argv):
|
||||
opts["registry"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--rounds" and i + 1 < len(argv):
|
||||
opts["rounds"] = int(argv[i + 1])
|
||||
i += 2
|
||||
elif arg == "--output" and i + 1 < len(argv):
|
||||
opts["output"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--dump-response":
|
||||
opts["dump_response"] = True
|
||||
i += 1
|
||||
else:
|
||||
print(f"未知参数: {arg}")
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
|
||||
def main():
|
||||
opts = parse_args(sys.argv[1:])
|
||||
|
||||
if not opts["auth"] or not opts["base"] or not opts["workflow_uuid"]:
|
||||
print("用法:")
|
||||
print(" python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]")
|
||||
print()
|
||||
print("必需参数:")
|
||||
print(" --auth <token> Lab token(base64(ak:sk))")
|
||||
print(" --base <url> API 基础 URL")
|
||||
print(" --workflow-uuid <uuid> 目标 workflow UUID")
|
||||
print()
|
||||
print("可选参数:")
|
||||
print(" --registry <path> 注册表文件路径(默认自动搜索)")
|
||||
print(" --rounds <n> 实验轮次数(默认 1)")
|
||||
print(" --output <path> 输出文件路径(默认 notebook_template.json)")
|
||||
print(" --dump-response 打印 API 原始响应")
|
||||
sys.exit(1)
|
||||
|
||||
# 1. 查找并加载本地注册表
|
||||
registry_path = find_registry(opts["registry"])
|
||||
registry_index = {}
|
||||
if registry_path:
|
||||
mtime = os.path.getmtime(registry_path)
|
||||
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"注册表: {registry_path} (生成时间: {gen_time})")
|
||||
registry_data = load_registry(registry_path)
|
||||
registry_index = build_registry_index(registry_data)
|
||||
print(f"已索引 {len(registry_index)} 个设备的 action schemas")
|
||||
else:
|
||||
print("警告: 未找到本地注册表,将跳过 param 模板生成")
|
||||
print(" 提交时需要手动填写各节点的 param 字段")
|
||||
|
||||
# 2. 获取 workflow 详情
|
||||
print(f"\n正在获取 workflow 详情: {opts['workflow_uuid']}")
|
||||
response = fetch_workflow_detail(opts["base"], opts["auth"], opts["workflow_uuid"])
|
||||
if not response:
|
||||
print("错误: 无法获取 workflow 详情")
|
||||
sys.exit(1)
|
||||
|
||||
if opts["dump_response"]:
|
||||
print("\n=== API 原始响应 ===")
|
||||
print(json.dumps(response, indent=2, ensure_ascii=False)[:5000])
|
||||
print("=== 响应结束(截断至 5000 字符) ===\n")
|
||||
|
||||
# 3. 提取节点
|
||||
nodes = extract_nodes_from_response(response)
|
||||
if not nodes:
|
||||
print("错误: 未能从 workflow 中提取任何 action 节点")
|
||||
print("请使用 --dump-response 查看原始响应结构")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n找到 {len(nodes)} 个 action 节点:")
|
||||
print(f" {'节点 UUID':<40} {'设备 ID':<30} {'动作名':<25} {'Schema'}")
|
||||
print(" " + "-" * 110)
|
||||
for node_uuid, resource_name, template_name, _ in nodes:
|
||||
matched = "✓" if (resource_name in registry_index and
|
||||
template_name in registry_index.get(resource_name, {})) else "✗"
|
||||
print(f" {node_uuid:<40} {resource_name:<30} {template_name:<25} {matched}")
|
||||
|
||||
# 4. 生成模板
|
||||
template = generate_template(nodes, registry_index, opts["rounds"])
|
||||
template["workflow_uuid"] = opts["workflow_uuid"]
|
||||
|
||||
output_path = opts["output"]
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(template, f, indent=2, ensure_ascii=False)
|
||||
print(f"\n模板已写入: {output_path}")
|
||||
print(f" 轮次数: {opts['rounds']}")
|
||||
print(f" 节点数/轮: {len(nodes)}")
|
||||
print()
|
||||
print("下一步:")
|
||||
print(" 1. 打开模板文件,将 $TODO 占位符替换为实际值")
|
||||
print(" 2. 删除 _schema_info 字段(仅供参考)")
|
||||
print(" 3. 使用 POST /api/v1/lab/notebook 提交")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -40,13 +40,13 @@ python ./scripts/gen_auth.py --config <config.py>
|
||||
|
||||
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
|
||||
|
||||
| `--addr` 值 | BASE URL |
|
||||
|-------------|----------|
|
||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
||||
| 其他自定义 URL | 直接使用该 URL |
|
||||
| `--addr` 值 | BASE URL |
|
||||
| -------------- | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
| 其他自定义 URL | 直接使用该 URL |
|
||||
|
||||
#### 必备项 ③:req_device_registry_upload.json(设备注册表)
|
||||
|
||||
@@ -54,11 +54,11 @@ python ./scripts/gen_auth.py --config <config.py>
|
||||
|
||||
**推断 working_dir**(即 `unilabos_data` 所在目录):
|
||||
|
||||
| 条件 | working_dir 取值 |
|
||||
|------|------------------|
|
||||
| 条件 | working_dir 取值 |
|
||||
| -------------------- | -------------------------------------------------------- |
|
||||
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
|
||||
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
||||
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
||||
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
||||
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
||||
|
||||
**按优先级搜索文件**:
|
||||
|
||||
@@ -84,24 +84,6 @@ python ./scripts/gen_auth.py --config <config.py>
|
||||
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
|
||||
```
|
||||
|
||||
#### 完整示例
|
||||
|
||||
用户提供:
|
||||
|
||||
```
|
||||
--ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd
|
||||
--sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b
|
||||
--addr test
|
||||
--port 8003
|
||||
--disable_browser
|
||||
```
|
||||
|
||||
从中提取:
|
||||
- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."`
|
||||
- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com`
|
||||
- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间
|
||||
- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi`
|
||||
|
||||
**四项全部就绪后才进入 Step 1。**
|
||||
|
||||
### Step 1 — 列出可用设备
|
||||
@@ -129,6 +111,7 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
|
||||
|
||||
每个 action 生成一个 JSON 文件,包含:
|
||||
|
||||
- `type` — 作为 API 调用的 `action_type`
|
||||
- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义)
|
||||
- `goal` — goal 字段映射(含占位符 `$placeholder`)
|
||||
@@ -136,13 +119,14 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
|
||||
### Step 3 — 写 action-index.md
|
||||
|
||||
按模板为每个 action 写条目:
|
||||
按模板为每个 action 写条目(**必须包含 `action_type`**):
|
||||
|
||||
```markdown
|
||||
### `<action_name>`
|
||||
|
||||
<用途描述(一句话)>
|
||||
|
||||
- **action_type**: `<从 actions/<name>.json 的 type 字段获取>`
|
||||
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
|
||||
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
|
||||
- **可选参数**: `param3`, `param4`
|
||||
@@ -150,6 +134,8 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
```
|
||||
|
||||
描述规则:
|
||||
|
||||
- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住
|
||||
- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容)
|
||||
- 从 `schema.required` 区分核心/可选参数
|
||||
- 按功能分类(移液、枪头、外设等)
|
||||
@@ -158,12 +144,14 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
- `unilabos_devices` → **DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device)
|
||||
- `unilabos_nodes` → **NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
|
||||
- `unilabos_class` → **ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
|
||||
- `unilabos_formulation` → **FormulationSlot**,填入配方数组 `[{well_name, liquids: [{name, volume}]}]`(well_name 为目标物料的 name)
|
||||
- array 类型字段 → `[{id, name, uuid}, ...]`
|
||||
- 特殊:`create_resource` 的 `res_id`(ResourceSlot)可填不存在的路径
|
||||
|
||||
### Step 4 — 写 SKILL.md
|
||||
|
||||
直接复用 `unilab-device-api` 的 API 模板(10 个 endpoint),修改:
|
||||
直接复用 `unilab-device-api` 的 API 模板,修改:
|
||||
|
||||
- 设备名称
|
||||
- Action 数量
|
||||
- 目录列表
|
||||
@@ -171,43 +159,96 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key)
|
||||
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
|
||||
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名)
|
||||
- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件
|
||||
|
||||
API 模板结构:
|
||||
|
||||
```markdown
|
||||
## 设备信息
|
||||
|
||||
- device_id, Python 源码路径, 设备类名
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
- ak/sk → AUTH, --addr → BASE URL
|
||||
|
||||
## Session State
|
||||
- lab_uuid(通过 API #1 自动匹配,不要问用户), device_name
|
||||
## 请求约定
|
||||
|
||||
## API Endpoints (10 个)
|
||||
# 注意:
|
||||
# - #1 获取 lab 列表 + 自动匹配 lab_uuid(遍历 is_admin 的 lab,
|
||||
# 调用 /lab/info/{uuid} 比对 access_key == ak)
|
||||
# - #2 创建工作流用 POST /lab/workflow
|
||||
# - #10 获取资源树路径含 lab_uuid: /lab/material/download/{lab_uuid}
|
||||
- Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名)
|
||||
|
||||
## Session State
|
||||
|
||||
- lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name
|
||||
|
||||
## API Endpoints
|
||||
|
||||
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
|
||||
|
||||
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
|
||||
|
||||
# - #3 创建节点 POST /edge/workflow/node
|
||||
|
||||
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
|
||||
|
||||
# - #4 删除节点 DELETE /lab/workflow/nodes
|
||||
|
||||
# - #5 更新节点参数 PATCH /lab/workflow/node
|
||||
|
||||
# - #6 查询节点 handles POST /lab/workflow/node-handles
|
||||
|
||||
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
|
||||
|
||||
# - #7 批量创建边 POST /lab/workflow/edges
|
||||
|
||||
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
|
||||
|
||||
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
|
||||
|
||||
# - #9 运行设备单动作 POST /lab/mcp/run/action(⚠️ action_type 必须从 action-index.md 或 actions/<name>.json 的 type 字段获取,传错会导致任务永远卡住)
|
||||
|
||||
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
|
||||
|
||||
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
|
||||
|
||||
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
|
||||
|
||||
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
|
||||
|
||||
# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
|
||||
|
||||
# - #14 按名称查询物料模板 GET /lab/material/template/by-name?lab_uuid=&name=
|
||||
|
||||
# 返回 res_template_uuid,用于 #15 创建物料时的必填字段
|
||||
|
||||
# - #15 创建物料节点 POST /edge/material/node
|
||||
|
||||
# body: {res_template_uuid(从#14获取), name(自定义), display_name, parent_uuid?(从#12获取), ...}
|
||||
|
||||
# - #16 更新物料节点 PUT /edge/material/node
|
||||
|
||||
# body: {uuid(从#12获取), display_name?, description?, init_param_data?, data?, ...}
|
||||
|
||||
## Placeholder Slot 填写规则
|
||||
|
||||
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
|
||||
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
|
||||
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
|
||||
- unilabos_class → ClassSlot → "class_name" 字符串
|
||||
- unilabos_formulation → FormulationSlot → [{well_name, liquids: [{name, volume}]}] 配方数组
|
||||
- 特例:create_resource 的 res_id 允许填不存在的路径
|
||||
- 列出本设备所有 Slot 字段、类型及含义
|
||||
|
||||
## 渐进加载策略
|
||||
|
||||
## 完整工作流 Checklist
|
||||
```
|
||||
|
||||
### Step 5 — 验证
|
||||
|
||||
检查文件完整性:
|
||||
- [ ] `SKILL.md` 包含 10 个 API endpoint
|
||||
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表
|
||||
|
||||
- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情、#14-#16 物料管理)
|
||||
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
|
||||
- [ ] `action-index.md` 列出所有 action 并有描述
|
||||
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
|
||||
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
|
||||
@@ -249,71 +290,202 @@ API 模板结构:
|
||||
```
|
||||
|
||||
> **注意**:`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
|
||||
> `schema.properties` 中的字段即为 API 请求 `param.goal` 中的字段。
|
||||
> `schema.properties` 中的字段即为 API 创建节点返回的 `data.param` 中的字段,PATCH 更新时直接修改 `param` 即可。
|
||||
|
||||
## Placeholder Slot 类型体系
|
||||
|
||||
`placeholder_keys` / `_unilabos_placeholder_info` 中有 4 种值,对应不同的填写方式:
|
||||
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
|
||||
|
||||
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||
|---------------|-----------|---------|---------|
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
|
||||
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||
| ---------------------- | --------------- | ----------------------------------------------------- | ----------------------------------------- |
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
|
||||
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
|
||||
|
||||
### ResourceSlot(`unilabos_resources`)
|
||||
|
||||
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
|
||||
|
||||
- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}`
|
||||
- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
||||
- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料
|
||||
|
||||
> **特例**:`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。
|
||||
|
||||
### DeviceSlot / NodeSlot / ClassSlot
|
||||
|
||||
- **DeviceSlot**(`unilabos_devices`):路径字符串如 `"/host_node"`,仅 type=device 的节点
|
||||
- **NodeSlot**(`unilabos_nodes`):路径字符串如 `"/PRCXI/PRCXI_Deck"`,设备 + 物料均可选
|
||||
- **ClassSlot**(`unilabos_class`):类名字符串如 `"container"`,从 `req_resource_registry_upload.json` 查找
|
||||
|
||||
### FormulationSlot(`unilabos_formulation`)
|
||||
|
||||
描述**液体配方**:向哪些容器中加入哪些液体及体积。
|
||||
|
||||
```json
|
||||
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
|
||||
[
|
||||
{
|
||||
"sample_uuid": "",
|
||||
"well_name": "bottle_A1",
|
||||
"liquids": [{ "name": "LiPF6", "volume": 0.6 }]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- 单个(schema type=object):`{"id": "/path/name", "name": "name", "uuid": "xxx"}`
|
||||
- 数组(schema type=array):`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
||||
- `id` 本身是从 parent 计算的路径格式
|
||||
- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置)
|
||||
- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径)
|
||||
- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`(体积,单位由上下文决定;pylabrobot 内部统一 uL)
|
||||
- `sample_uuid` — 样品 UUID,无样品传 `""`
|
||||
- 与 ResourceSlot 的区别:ResourceSlot 指向物料本身,FormulationSlot 引用物料名并附带配方信息
|
||||
|
||||
> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
|
||||
|
||||
### DeviceSlot(`unilabos_devices`)
|
||||
|
||||
填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径:
|
||||
|
||||
```
|
||||
"/host_node"
|
||||
"/bioyond_cell/reaction_station"
|
||||
```
|
||||
|
||||
- 只填路径字符串,不需要 `{id, uuid}` 对象
|
||||
- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备)
|
||||
|
||||
### NodeSlot(`unilabos_nodes`)
|
||||
|
||||
范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**:
|
||||
|
||||
```
|
||||
"/PRCXI/PRCXI_Deck"
|
||||
```
|
||||
|
||||
- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`)
|
||||
|
||||
### ClassSlot(`unilabos_class`)
|
||||
|
||||
填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找:
|
||||
|
||||
```
|
||||
"container"
|
||||
```
|
||||
|
||||
### 通过 API #10 获取资源树
|
||||
### 通过 API #12 获取资源树
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
|
||||
注意 `lab_uuid` 在路径中(不是查询参数)。返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"nodes": [
|
||||
{"name": "host_node", "uuid": "c3ec1e68-...", "type": "device", "parent": ""},
|
||||
{"name": "PRCXI", "uuid": "e249c9a6-...", "type": "device", "parent": ""},
|
||||
{"name": "PRCXI_Deck", "uuid": "fb6a8b71-...", "type": "deck", "parent": "PRCXI"}
|
||||
],
|
||||
"edges": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `data.nodes[]` — 所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`
|
||||
- `type` 区分设备(`device`)和物料(`deck`、`container`、`resource` 等)
|
||||
- `parent` 为父节点名称(空字符串表示顶级)
|
||||
- 填写 Slot 时根据 placeholder 类型筛选:ResourceSlot 取非 device 节点,DeviceSlot 取 device 节点
|
||||
- 创建/更新物料时:`parent_uuid` 取父节点的 `uuid`,更新目标的 `uuid` 取节点自身的 `uuid`
|
||||
|
||||
## 物料管理 API
|
||||
|
||||
设备 Skill 除了设备动作外,还需支持物料节点的创建和参数设定,用于在资源树中动态管理物料。
|
||||
|
||||
典型流程:先通过 **#14 按名称查询模板** 获取 `res_template_uuid` → 再通过 **#15 创建物料** → 之后可通过 **#16 更新物料** 修改属性。更新时需要的 `uuid` 和 `parent_uuid` 均从 **#12 资源树下载** 获取。
|
||||
|
||||
### API #14 — 按名称查询物料模板
|
||||
|
||||
创建物料前,需要先获取物料模板的 UUID。通过模板名称查询:
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| ---------- | ------ | -------------------------------- |
|
||||
| `lab_uuid` | **是** | 实验室 UUID(从 API #1 获取) |
|
||||
| `name` | **是** | 物料模板名称(如 `"container"`) |
|
||||
|
||||
返回 `code: 0` 时,**`data.uuid`** 即为 `res_template_uuid`,用于 API #15 创建物料。返回还包含 `name`、`resource_type`、`handles`、`config_infos` 等模板元信息。
|
||||
|
||||
模板不存在时返回 `code: 10002`,`data` 为空对象。模板名称来自资源注册表中已注册的资源类型。
|
||||
|
||||
### API #15 — 创建物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"res_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"name": "my_custom_bottle",
|
||||
"display_name": "自定义瓶子",
|
||||
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"type": "",
|
||||
"init_param_data": {},
|
||||
"schema": {},
|
||||
"data": {
|
||||
"liquids": [["water", 1000, "uL"]],
|
||||
"max_volume": 50000
|
||||
},
|
||||
"plate_well_datas": {},
|
||||
"plate_reagent_datas": {},
|
||||
"pose": {},
|
||||
"model": {}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
|
||||
| --------------------- | ------ | ------------- | ----------------------------------- | -------------------------------------- |
|
||||
| `res_template_uuid` | **是** | string (UUID) | **API #14** 按名称查询获取 | 物料模板 UUID |
|
||||
| `name` | 否 | string | **用户自定义** | 节点名称(标识符),可自由命名 |
|
||||
| `display_name` | 否 | string | 用户自定义 | 显示名称(UI 展示用) |
|
||||
| `parent_uuid` | 否 | string (UUID) | **API #12** 资源树中父节点的 `uuid` | 父节点,为空则创建顶级节点 |
|
||||
| `type` | 否 | string | 从模板继承 | 节点类型 |
|
||||
| `init_param_data` | 否 | object | 用户指定 | 初始化参数,覆盖模板默认值 |
|
||||
| `data` | 否 | object | 用户指定 | 节点数据,container 见下方 data 格式 |
|
||||
| `plate_well_datas` | 否 | object | 用户指定 | 孔板子节点数据(创建带孔位的板时使用) |
|
||||
| `plate_reagent_datas` | 否 | object | 用户指定 | 试剂关联数据 |
|
||||
| `schema` | 否 | object | 从模板继承 | 自定义 schema,不传则从模板继承 |
|
||||
| `pose` | 否 | object | 用户指定 | 位姿信息 |
|
||||
| `model` | 否 | object | 用户指定 | 3D 模型信息 |
|
||||
|
||||
#### container 的 `data` 格式
|
||||
|
||||
> **体积单位统一为 uL(微升)**。pylabrobot 体系中所有体积值(`max_volume`、`liquids` 中的 volume)均为 uL。外部如果是 mL 需乘 1000 转换。
|
||||
|
||||
```json
|
||||
{
|
||||
"liquids": [["water", 1000, "uL"], ["ethanol", 500, "uL"]],
|
||||
"max_volume": 50000
|
||||
}
|
||||
```
|
||||
|
||||
- `liquids` — 液体列表,每条为 `[液体名称, 体积(uL), 单位字符串]`
|
||||
- `max_volume` — 容器最大容量(uL),如 50 mL = 50000 uL
|
||||
|
||||
### API #16 — 更新物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"display_name": "新显示名称",
|
||||
"description": "新描述",
|
||||
"init_param_data": {},
|
||||
"data": {},
|
||||
"pose": {},
|
||||
"schema": {},
|
||||
"extra": {}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
|
||||
| ----------------- | ------ | ------------- | ------------------------------------- | ---------------- |
|
||||
| `uuid` | **是** | string (UUID) | **API #12** 资源树中目标节点的 `uuid` | 要更新的物料节点 |
|
||||
| `parent_uuid` | 否 | string (UUID) | API #12 资源树 | 移动到新父节点 |
|
||||
| `display_name` | 否 | string | 用户指定 | 更新显示名称 |
|
||||
| `description` | 否 | string | 用户指定 | 更新描述 |
|
||||
| `init_param_data` | 否 | object | 用户指定 | 更新初始化参数 |
|
||||
| `data` | 否 | object | 用户指定 | 更新节点数据 |
|
||||
| `pose` | 否 | object | 用户指定 | 更新位姿 |
|
||||
| `schema` | 否 | object | 用户指定 | 更新 schema |
|
||||
| `extra` | 否 | object | 用户指定 | 更新扩展数据 |
|
||||
|
||||
> 只传需要更新的字段,未传的字段保持不变。
|
||||
|
||||
## 最终目录结构
|
||||
|
||||
|
||||
251
.cursor/skills/host-node/SKILL.md
Normal file
251
.cursor/skills/host-node/SKILL.md
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
name: host-node
|
||||
description: Operate Uni-Lab host node via REST API — create resources, test latency, test resource tree, manual confirm. Use when the user mentions host_node, creating resources, resource management, testing latency, or any host node operation.
|
||||
---
|
||||
|
||||
# Host Node API Skill
|
||||
|
||||
## 设备信息
|
||||
|
||||
- **device_id**: `host_node`
|
||||
- **Python 源码**: `unilabos/ros/nodes/presets/host_node.py`
|
||||
- **设备类**: `HostNode`
|
||||
- **动作数**: 4(`create_resource`, `test_latency`, `auto-test_resource`, `manual_confirm`)
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab <token>`
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
AUTH="Authorization: Lab <token>"
|
||||
```
|
||||
|
||||
**两项全部就绪后才可发起 API 请求。**
|
||||
|
||||
## Session State
|
||||
|
||||
在整个对话过程中,agent 需要记住以下状态,避免重复询问用户:
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||
- `device_name` — `host_node`
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。
|
||||
|
||||
### 2. 创建工作流
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
|
||||
|
||||
### 3. 创建节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"host_node","node_template_name":"<action_name>"}'
|
||||
```
|
||||
|
||||
- `resource_template_name` 固定为 `host_node`
|
||||
- `node_template_name` — action 名称(如 `create_resource`, `test_latency`)
|
||||
|
||||
### 4. 删除节点
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
|
||||
```
|
||||
|
||||
### 5. 更新节点参数
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
|
||||
```
|
||||
|
||||
`param` 直接使用创建节点返回的 `data.param` 结构,修改需要填入的字段值。参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
|
||||
|
||||
### 6. 查询节点 handles
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
|
||||
```
|
||||
|
||||
### 7. 批量创建边
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
|
||||
```
|
||||
|
||||
### 8. 启动工作流
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
|
||||
```
|
||||
|
||||
### 9. 运行设备单动作
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"lab_uuid":"<lab_uuid>","device_id":"host_node","action":"<action_name>","action_type":"<type>","param":{...}}'
|
||||
```
|
||||
|
||||
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。
|
||||
|
||||
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
|
||||
|
||||
#### action_type 速查表
|
||||
|
||||
| action | action_type |
|
||||
|--------|-------------|
|
||||
| `test_latency` | `UniLabJsonCommand` |
|
||||
| `create_resource` | `ResourceCreateFromOuterEasy` |
|
||||
| `auto-test_resource` | `UniLabJsonCommand` |
|
||||
| `manual_confirm` | `UniLabJsonCommand` |
|
||||
|
||||
### 10. 查询任务状态
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
|
||||
```
|
||||
|
||||
### 11. 运行工作流单节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuid":"<node_uuid>"}'
|
||||
```
|
||||
|
||||
### 12. 获取资源树(物料信息)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。
|
||||
|
||||
### 13. 获取工作流模板详情
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
|
||||
|
||||
### 14. 按名称查询物料模板
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。
|
||||
|
||||
### 15. 创建物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
|
||||
```
|
||||
|
||||
### 16. 更新物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Placeholder Slot 填写规则
|
||||
|
||||
| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||
| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- |
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 |
|
||||
|
||||
### host_node 设备的 Slot 字段表
|
||||
|
||||
| Action | 字段 | Slot 类型 | 说明 |
|
||||
| ----------------- | ----------- | ------------ | ------------------------------ |
|
||||
| `create_resource` | `res_id` | ResourceSlot | 新资源路径(可填不存在的路径) |
|
||||
| `create_resource` | `device_id` | DeviceSlot | 归属设备 |
|
||||
| `create_resource` | `parent` | NodeSlot | 父节点路径 |
|
||||
| `create_resource` | `class_name`| ClassSlot | 资源类名如 `"container"` |
|
||||
| `auto-test_resource` | `resource` | ResourceSlot | 单个测试物料 |
|
||||
| `auto-test_resource` | `resources` | ResourceSlot | 测试物料数组 |
|
||||
| `auto-test_resource` | `device` | DeviceSlot | 测试设备 |
|
||||
| `auto-test_resource` | `devices` | DeviceSlot | 测试设备 |
|
||||
|
||||
---
|
||||
|
||||
## 渐进加载策略
|
||||
|
||||
1. **SKILL.md**(本文件)— API 端点 + session state 管理
|
||||
2. **[action-index.md](action-index.md)** — 按分类浏览 4 个动作的描述和核心参数
|
||||
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
|
||||
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
|
||||
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
|
||||
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接
|
||||
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=host_node) → 记住 node_uuid + data.param
|
||||
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
|
||||
- [ ] Step 7: 更新节点参数 (PATCH #5)
|
||||
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
|
||||
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
|
||||
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
|
||||
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
|
||||
```
|
||||
58
.cursor/skills/host-node/action-index.md
Normal file
58
.cursor/skills/host-node/action-index.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Action Index — host_node
|
||||
|
||||
4 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`。
|
||||
|
||||
---
|
||||
|
||||
## 资源管理
|
||||
|
||||
### `create_resource`
|
||||
|
||||
在资源树中创建新资源(容器、物料等),支持指定位置、类型和初始液体
|
||||
|
||||
- **action_type**: `ResourceCreateFromOuterEasy`
|
||||
- **Schema**: [`actions/create_resource.json`](actions/create_resource.json)
|
||||
- **可选参数**: `res_id`, `device_id`, `class_name`, `parent`, `bind_locations`, `liquid_input_slot`, `liquid_type`, `liquid_volume`, `slot_on_deck`
|
||||
- **占位符字段**:
|
||||
- `res_id` — **ResourceSlot**(特例:目标物料可能尚不存在,直接填期望路径)
|
||||
- `device_id` — **DeviceSlot**,填路径字符串如 `"/host_node"`
|
||||
- `parent` — **NodeSlot**,填路径字符串如 `"/workstation/deck"`
|
||||
- `class_name` — **ClassSlot**,填类名如 `"container"`
|
||||
|
||||
### `auto-test_resource`
|
||||
|
||||
测试资源系统,返回当前资源树和设备列表
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/test_resource.json`](actions/test_resource.json)
|
||||
- **可选参数**: `resource`, `resources`, `device`, `devices`
|
||||
- **占位符字段**:
|
||||
- `resource` — **ResourceSlot**,单个物料节点 `{id, name, uuid}`
|
||||
- `resources` — **ResourceSlot**,物料节点数组 `[{id, name, uuid}, ...]`
|
||||
- `device` — **DeviceSlot**,设备路径字符串
|
||||
- `devices` — **DeviceSlot**,设备路径字符串
|
||||
|
||||
---
|
||||
|
||||
## 系统工具
|
||||
|
||||
### `test_latency`
|
||||
|
||||
测试设备通信延迟,返回 RTT、时间差、任务延迟等指标
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/test_latency.json`](actions/test_latency.json)
|
||||
- **参数**: 无(零参数调用)
|
||||
|
||||
---
|
||||
|
||||
## 人工确认
|
||||
|
||||
### `manual_confirm`
|
||||
|
||||
创建人工确认节点,等待用户手动确认后继续
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
|
||||
- **核心参数**: `timeout_seconds`(超时时间,秒), `assignee_user_ids`(指派用户 ID 列表)
|
||||
- **占位符字段**: `assignee_user_ids` — `unilabos_manual_confirm` 类型
|
||||
93
.cursor/skills/host-node/actions/create_resource.json
Normal file
93
.cursor/skills/host-node/actions/create_resource.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"type": "ResourceCreateFromOuterEasy",
|
||||
"goal": {
|
||||
"res_id": "res_id",
|
||||
"class_name": "class_name",
|
||||
"parent": "parent",
|
||||
"device_id": "device_id",
|
||||
"bind_locations": "bind_locations",
|
||||
"liquid_input_slot": "liquid_input_slot[]",
|
||||
"liquid_type": "liquid_type[]",
|
||||
"liquid_volume": "liquid_volume[]",
|
||||
"slot_on_deck": "slot_on_deck"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"res_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"device_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"class_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"bind_locations": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "bind_locations",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"liquid_input_slot": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"liquid_type": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"liquid_volume": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"slot_on_deck": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"_unilabos_placeholder_info": {
|
||||
"res_id": "unilabos_resources",
|
||||
"device_id": "unilabos_devices",
|
||||
"parent": "unilabos_nodes",
|
||||
"class_name": "unilabos_class"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"res_id": "unilabos_resources",
|
||||
"device_id": "unilabos_devices",
|
||||
"parent": "unilabos_nodes",
|
||||
"class_name": "unilabos_class"
|
||||
}
|
||||
}
|
||||
32
.cursor/skills/host-node/actions/manual_confirm.json
Normal file
32
.cursor/skills/host-node/actions/manual_confirm.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"timeout_seconds": "timeout_seconds",
|
||||
"assignee_user_ids": "assignee_user_ids"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timeout_seconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"assignee_user_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"timeout_seconds",
|
||||
"assignee_user_ids"
|
||||
],
|
||||
"_unilabos_placeholder_info": {
|
||||
"assignee_user_ids": "unilabos_manual_confirm"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"assignee_user_ids": "unilabos_manual_confirm"
|
||||
}
|
||||
}
|
||||
11
.cursor/skills/host-node/actions/test_latency.json
Normal file
11
.cursor/skills/host-node/actions/test_latency.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
255
.cursor/skills/host-node/actions/test_resource.json
Normal file
255
.cursor/skills/host-node/actions/test_resource.json
Normal file
@@ -0,0 +1,255 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"resource": "resource",
|
||||
"resources": "resources",
|
||||
"device": "device",
|
||||
"devices": "devices"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resource": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "resource"
|
||||
},
|
||||
"resources": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "resources"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"device": {
|
||||
"type": "string",
|
||||
"description": "device reference"
|
||||
},
|
||||
"devices": {
|
||||
"type": "string",
|
||||
"description": "device reference"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"_unilabos_placeholder_info": {
|
||||
"resource": "unilabos_resources",
|
||||
"resources": "unilabos_resources",
|
||||
"device": "unilabos_devices",
|
||||
"devices": "unilabos_devices"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"resource": "unilabos_resources",
|
||||
"resources": "unilabos_resources",
|
||||
"device": "unilabos_devices",
|
||||
"devices": "unilabos_devices"
|
||||
}
|
||||
}
|
||||
284
.cursor/skills/submit-agent-result/SKILL.md
Normal file
284
.cursor/skills/submit-agent-result/SKILL.md
Normal file
@@ -0,0 +1,284 @@
|
||||
---
|
||||
name: submit-agent-result
|
||||
description: Submit historical experiment results (agent_result) to Uni-Lab cloud platform (leap-lab) notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
|
||||
---
|
||||
|
||||
# Uni-Lab 提交历史实验记录指南
|
||||
|
||||
通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。
|
||||
|
||||
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
||||
|
||||
生成 AUTH token:
|
||||
|
||||
```bash
|
||||
# ⚠️ 注意:scheme 是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
```
|
||||
|
||||
输出即为 token 值,拼接为 `Authorization: Lab <token>`(`Lab` 是 Uni-Lab 平台 auth scheme,不可替换为 `Basic`)。
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
```
|
||||
|
||||
### 3. notebook_uuid(**必须询问用户**)
|
||||
|
||||
**必须主动询问用户**:「请提供要提交结果的 notebook UUID。」
|
||||
|
||||
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。
|
||||
|
||||
如果用户不记得,可提示:
|
||||
|
||||
- 查看之前的对话记录中创建 notebook 时返回的 UUID
|
||||
- 或通过平台页面查找对应的 notebook
|
||||
|
||||
**绝不能跳过此步骤,没有 notebook_uuid 无法提交。**
|
||||
|
||||
### 4. 实验结果数据
|
||||
|
||||
用户需要提供实验结果数据,支持以下方式:
|
||||
|
||||
| 方式 | 说明 |
|
||||
| --------- | ----------------------------------------------- |
|
||||
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
|
||||
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
|
||||
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
|
||||
|
||||
**四项全部就绪后才可开始。**
|
||||
|
||||
## Session State
|
||||
|
||||
在整个对话过程中,agent 需要记住以下状态:
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(通过 API #1 自动获取,**不需要问用户**)
|
||||
- `notebook_uuid` — 目标 notebook UUID(**必须询问用户**)
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,PUT 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
||||
>
|
||||
> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则 `@` 会被 PowerShell 解析为 splatting 运算符导致报错)。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
|
||||
### 2. 提交实验结果(agent_result)
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"notebook_uuid": "<notebook_uuid>",
|
||||
"agent_result": {
|
||||
"<key1>": "<value1>",
|
||||
"<key2>": 123,
|
||||
"<nested_key>": {"a": 1, "b": 2},
|
||||
"<array_key>": [{"col1": "v1", "col2": "v2"}, ...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:HTTP 方法是 **PUT**(不是 POST)。
|
||||
|
||||
#### 必要字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------------- | ------------- | ------------------------------------------- |
|
||||
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 |
|
||||
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
|
||||
|
||||
#### agent_result 内容格式
|
||||
|
||||
`agent_result` 接受**任意 JSON 对象**,常见格式:
|
||||
|
||||
**简单键值对**:
|
||||
|
||||
```json
|
||||
{
|
||||
"avg_rtt_ms": 12.5,
|
||||
"status": "success",
|
||||
"test_count": 5
|
||||
}
|
||||
```
|
||||
|
||||
**包含嵌套结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": { "total": 100, "passed": 98, "failed": 2 },
|
||||
"measurements": [
|
||||
{ "sample_id": "S001", "value": 3.14, "unit": "mg/mL" },
|
||||
{ "sample_id": "S002", "value": 2.71, "unit": "mg/mL" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**从 CSV 文件导入**(脚本自动转换):
|
||||
|
||||
```json
|
||||
{
|
||||
"experiment_data": [
|
||||
{ "温度": 25, "压力": 101.3, "产率": 0.85 },
|
||||
{ "温度": 30, "压力": 101.3, "产率": 0.91 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 整合脚本
|
||||
|
||||
本文档同级目录下的 `scripts/prepare_agent_result.py` 可自动读取文件并构建请求体。
|
||||
|
||||
### 用法
|
||||
|
||||
```bash
|
||||
python scripts/prepare_agent_result.py \
|
||||
--notebook-uuid <uuid> \
|
||||
--files data1.json data2.csv \
|
||||
[--auth <token>] \
|
||||
[--base <BASE_URL>] \
|
||||
[--submit] \
|
||||
[--output <output.json>]
|
||||
```
|
||||
|
||||
| 参数 | 必选 | 说明 |
|
||||
| ----------------- | ---------- | ----------------------------------------------- |
|
||||
| `--notebook-uuid` | 是 | 目标 notebook UUID |
|
||||
| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) |
|
||||
| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) |
|
||||
| `--base` | 提交时必选 | API base URL |
|
||||
| `--submit` | 否 | 加上此标志则直接提交到云端 |
|
||||
| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json`) |
|
||||
|
||||
### 文件合并规则
|
||||
|
||||
| 文件类型 | 合并方式 |
|
||||
| --------------------- | -------------------------------------------- |
|
||||
| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 |
|
||||
| `.json`(list/other) | 以文件名为 key 放入 `agent_result` |
|
||||
| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 |
|
||||
|
||||
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 仅生成请求体文件(不提交)
|
||||
python scripts/prepare_agent_result.py \
|
||||
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
||||
--files results.json measurements.csv
|
||||
|
||||
# 生成并直接提交
|
||||
python scripts/prepare_agent_result.py \
|
||||
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
||||
--files results.json \
|
||||
--auth YTFmZDlkNGUt... \
|
||||
--base https://leap-lab.test.bohrium.com \
|
||||
--submit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 手动构建方式
|
||||
|
||||
如果不使用脚本,也可手动构建请求体:
|
||||
|
||||
1. 将实验结果数据组装为 JSON 对象
|
||||
2. 写入临时文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"notebook_uuid": "<uuid>",
|
||||
"agent_result": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
3. 用 curl 提交:
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '@tmp_body.json'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||
- [ ] Step 4: **询问用户** notebook_uuid(必须,不可跳过)
|
||||
- [ ] Step 5: 确认实验结果数据来源(文件路径或手动数据)
|
||||
- [ ] Step 6: 运行 prepare_agent_result.py 或手动构建请求体
|
||||
- [ ] Step 7: PUT /lab/notebook/agent-result 提交
|
||||
- [ ] Step 8: 检查返回结果,确认提交成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: notebook_uuid 从哪里获取?
|
||||
|
||||
从之前「批量提交实验」时 `POST /api/v1/lab/notebook` 的返回值 `data.uuid` 获取。也可以在平台 UI 中查找对应的 notebook。
|
||||
|
||||
### Q: agent_result 有固定的 schema 吗?
|
||||
|
||||
没有严格 schema,接受任意 JSON 对象。但建议包含有意义的字段名和结构化数据,方便后续分析。
|
||||
|
||||
### Q: 可以多次提交同一个 notebook 的结果吗?
|
||||
|
||||
可以,后续提交会覆盖之前的 agent_result。
|
||||
|
||||
### Q: 认证方式是 Lab 还是 Api?
|
||||
|
||||
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme,**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key,也可用 `Authorization: Api <key>` 替代。
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
读取实验结果文件(JSON / CSV),整合为 agent_result 请求体并可选提交。
|
||||
|
||||
用法:
|
||||
python prepare_agent_result.py \
|
||||
--notebook-uuid <uuid> \
|
||||
--files data1.json data2.csv \
|
||||
[--auth <Lab token>] \
|
||||
[--base <BASE_URL>] \
|
||||
[--submit] \
|
||||
[--output <output.json>]
|
||||
|
||||
支持的输入文件格式:
|
||||
- .json → 直接作为 dict 合并
|
||||
- .csv → 转为 {"filename": [row_dict, ...]} 格式
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def read_json_file(filepath: str) -> Dict[str, Any]:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def read_csv_file(filepath: str) -> List[Dict[str, Any]]:
|
||||
rows = []
|
||||
with open(filepath, "r", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
converted = {}
|
||||
for k, v in row.items():
|
||||
try:
|
||||
converted[k] = int(v)
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
converted[k] = float(v)
|
||||
except (ValueError, TypeError):
|
||||
converted[k] = v
|
||||
rows.append(converted)
|
||||
return rows
|
||||
|
||||
|
||||
def merge_files(filepaths: List[str]) -> Dict[str, Any]:
|
||||
"""将多个文件合并为一个 agent_result dict"""
|
||||
merged: Dict[str, Any] = {}
|
||||
for fp in filepaths:
|
||||
path = Path(fp)
|
||||
ext = path.suffix.lower()
|
||||
key = path.stem
|
||||
|
||||
if ext == ".json":
|
||||
data = read_json_file(fp)
|
||||
if isinstance(data, dict):
|
||||
merged.update(data)
|
||||
else:
|
||||
merged[key] = data
|
||||
elif ext == ".csv":
|
||||
merged[key] = read_csv_file(fp)
|
||||
else:
|
||||
print(f"[警告] 不支持的文件格式: {fp},跳过", file=sys.stderr)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def build_request_body(notebook_uuid: str, agent_result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"notebook_uuid": notebook_uuid,
|
||||
"agent_result": agent_result,
|
||||
}
|
||||
|
||||
|
||||
def submit(base: str, auth: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("[错误] 提交需要 requests 库: pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
url = f"{base}/api/v1/lab/notebook/agent-result"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Lab {auth}",
|
||||
}
|
||||
resp = requests.put(url, json=body, headers=headers, timeout=30)
|
||||
return {"status_code": resp.status_code, "body": resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="整合实验结果文件并构建 agent_result 请求体")
|
||||
parser.add_argument("--notebook-uuid", required=True, help="目标 notebook UUID")
|
||||
parser.add_argument("--files", nargs="+", required=True, help="输入文件路径(JSON / CSV)")
|
||||
parser.add_argument("--auth", help="Lab token(base64(ak:sk))")
|
||||
parser.add_argument("--base", help="API base URL")
|
||||
parser.add_argument("--submit", action="store_true", help="直接提交到云端")
|
||||
parser.add_argument("--output", default="agent_result_body.json", help="输出 JSON 文件路径")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
for fp in args.files:
|
||||
if not os.path.exists(fp):
|
||||
print(f"[错误] 文件不存在: {fp}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
agent_result = merge_files(args.files)
|
||||
body = build_request_body(args.notebook_uuid, agent_result)
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(body, f, ensure_ascii=False, indent=2)
|
||||
print(f"[完成] 请求体已保存: {args.output}")
|
||||
print(f" notebook_uuid: {args.notebook_uuid}")
|
||||
print(f" agent_result 字段数: {len(agent_result)}")
|
||||
print(f" 合并文件数: {len(args.files)}")
|
||||
|
||||
if args.submit:
|
||||
if not args.auth or not args.base:
|
||||
print("[错误] 提交需要 --auth 和 --base 参数", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"\n[提交] PUT {args.base}/api/v1/lab/notebook/agent-result ...")
|
||||
result = submit(args.base, args.auth, body)
|
||||
print(f" HTTP {result['status_code']}")
|
||||
print(f" 响应: {json.dumps(result['body'], ensure_ascii=False)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
272
.cursor/skills/virtual-workbench/SKILL.md
Normal file
272
.cursor/skills/virtual-workbench/SKILL.md
Normal file
@@ -0,0 +1,272 @@
|
||||
---
|
||||
name: virtual-workbench
|
||||
description: Operate Virtual Workbench via REST API — prepare materials, move to heating stations, start heating, move to output, transfer resources. Use when the user mentions virtual workbench, virtual_workbench, 虚拟工作台, heating stations, material processing, or workbench operations.
|
||||
---
|
||||
|
||||
# Virtual Workbench API Skill
|
||||
|
||||
## 设备信息
|
||||
|
||||
- **device_id**: `virtual_workbench`
|
||||
- **Python 源码**: `unilabos/devices/virtual/workbench.py`
|
||||
- **设备类**: `VirtualWorkbench`
|
||||
- **动作数**: 6(`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm`)
|
||||
- **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s,独占锁)和 3 个加热台(每次加热 60s,可并行)
|
||||
|
||||
### 典型工作流程
|
||||
|
||||
1. `prepare_materials` — 生成 A1-A5 物料(5 个 output handle)
|
||||
2. `move_to_heating_station` — 物料并发竞争机械臂,移动到空闲加热台
|
||||
3. `start_heating` — 启动加热(3 个加热台可并行)
|
||||
4. `move_to_output` — 加热完成后移到输出位置 Cn
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab <token>`
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
AUTH="Authorization: Lab <token>"
|
||||
```
|
||||
|
||||
**两项全部就绪后才可发起 API 请求。**
|
||||
|
||||
## Session State
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||
- `device_name` — `virtual_workbench`
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。
|
||||
|
||||
### 2. 创建工作流
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
|
||||
|
||||
### 3. 创建节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"virtual_workbench","node_template_name":"<action_name>"}'
|
||||
```
|
||||
|
||||
- `resource_template_name` 固定为 `virtual_workbench`
|
||||
- `node_template_name` — action 名称(如 `auto-prepare_materials`, `auto-move_to_heating_station`)
|
||||
|
||||
### 4. 删除节点
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
|
||||
```
|
||||
|
||||
### 5. 更新节点参数
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
|
||||
```
|
||||
|
||||
参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
|
||||
|
||||
### 6. 查询节点 handles
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
|
||||
```
|
||||
|
||||
### 7. 批量创建边
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
|
||||
```
|
||||
|
||||
### 8. 启动工作流
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
|
||||
```
|
||||
|
||||
### 9. 运行设备单动作
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"lab_uuid":"<lab_uuid>","device_id":"virtual_workbench","action":"<action_name>","action_type":"<type>","param":{...}}'
|
||||
```
|
||||
|
||||
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。
|
||||
|
||||
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
|
||||
|
||||
#### action_type 速查表
|
||||
|
||||
| action | action_type |
|
||||
|--------|-------------|
|
||||
| `auto-prepare_materials` | `UniLabJsonCommand` |
|
||||
| `auto-move_to_heating_station` | `UniLabJsonCommand` |
|
||||
| `auto-start_heating` | `UniLabJsonCommand` |
|
||||
| `auto-move_to_output` | `UniLabJsonCommand` |
|
||||
| `transfer` | `UniLabJsonCommandAsync` |
|
||||
| `manual_confirm` | `UniLabJsonCommand` |
|
||||
|
||||
### 10. 查询任务状态
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
|
||||
```
|
||||
|
||||
### 11. 运行工作流单节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuid":"<node_uuid>"}'
|
||||
```
|
||||
|
||||
### 12. 获取资源树(物料信息)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。
|
||||
|
||||
### 13. 获取工作流模板详情
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
|
||||
|
||||
### 14. 按名称查询物料模板
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。
|
||||
|
||||
### 15. 创建物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
|
||||
```
|
||||
|
||||
### 16. 更新物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Placeholder Slot 填写规则
|
||||
|
||||
| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||
| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- |
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 |
|
||||
|
||||
### virtual_workbench 设备的 Slot 字段表
|
||||
|
||||
| Action | 字段 | Slot 类型 | 说明 |
|
||||
| ----------------- | ---------------- | ------------ | -------------------- |
|
||||
| `transfer` | `resource` | ResourceSlot | 待转移物料数组 |
|
||||
| `transfer` | `target_device` | DeviceSlot | 目标设备路径 |
|
||||
| `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 |
|
||||
| `manual_confirm` | `resource` | ResourceSlot | 确认用物料数组 |
|
||||
| `manual_confirm` | `target_device` | DeviceSlot | 确认用目标设备 |
|
||||
| `manual_confirm` | `mount_resource` | ResourceSlot | 确认用目标孔位数组 |
|
||||
|
||||
> `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。
|
||||
|
||||
---
|
||||
|
||||
## 渐进加载策略
|
||||
|
||||
1. **SKILL.md**(本文件)— API 端点 + session state 管理 + 设备工作流概览
|
||||
2. **[action-index.md](action-index.md)** — 按分类浏览 6 个动作的描述和核心参数
|
||||
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
|
||||
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
|
||||
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
|
||||
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接
|
||||
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=virtual_workbench) → 记住 node_uuid + data.param
|
||||
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
|
||||
- [ ] Step 7: 更新节点参数 (PATCH #5)
|
||||
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
|
||||
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
|
||||
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
|
||||
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
|
||||
```
|
||||
|
||||
### 典型 5 物料并发加热工作流示例
|
||||
|
||||
```
|
||||
prepare_materials (count=5)
|
||||
├─ channel_1 → move_to_heating_station (material_number=1) → start_heating → move_to_output
|
||||
├─ channel_2 → move_to_heating_station (material_number=2) → start_heating → move_to_output
|
||||
├─ channel_3 → move_to_heating_station (material_number=3) → start_heating → move_to_output
|
||||
├─ channel_4 → move_to_heating_station (material_number=4) → start_heating → move_to_output
|
||||
└─ channel_5 → move_to_heating_station (material_number=5) → start_heating → move_to_output
|
||||
```
|
||||
|
||||
创建节点时,`prepare_materials` 的 5 个 output handle(`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station` 的 `heating_station_output` 和 `material_number_output` 连接到对应 `start_heating` 的 `station_id_input` 和 `material_number_input`。
|
||||
76
.cursor/skills/virtual-workbench/action-index.md
Normal file
76
.cursor/skills/virtual-workbench/action-index.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Action Index — virtual_workbench
|
||||
|
||||
6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`。
|
||||
|
||||
---
|
||||
|
||||
## 物料准备
|
||||
|
||||
### `auto-prepare_materials`
|
||||
|
||||
批量准备物料(虚拟起始节点),生成 A1-A5 物料编号,输出 5 个 handle 供后续节点使用
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/prepare_materials.json`](actions/prepare_materials.json)
|
||||
- **可选参数**: `count`(物料数量,默认 5)
|
||||
|
||||
---
|
||||
|
||||
## 机械臂 & 加热台操作
|
||||
|
||||
### `auto-move_to_heating_station`
|
||||
|
||||
将物料从 An 位置移动到空闲加热台(竞争机械臂,自动查找空闲加热台)
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/move_to_heating_station.json`](actions/move_to_heating_station.json)
|
||||
- **核心参数**: `material_number`(物料编号,integer)
|
||||
|
||||
### `auto-start_heating`
|
||||
|
||||
启动指定加热台的加热程序(可并行,3 个加热台同时工作)
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/start_heating.json`](actions/start_heating.json)
|
||||
- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号)
|
||||
|
||||
### `auto-move_to_output`
|
||||
|
||||
将加热完成的物料从加热台移动到输出位置 Cn
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/move_to_output.json`](actions/move_to_output.json)
|
||||
- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号)
|
||||
|
||||
---
|
||||
|
||||
## 物料转移
|
||||
|
||||
### `transfer`
|
||||
|
||||
异步转移物料到目标设备(通过 ROS 资源转移)
|
||||
|
||||
- **action_type**: `UniLabJsonCommandAsync`
|
||||
- **Schema**: [`actions/transfer.json`](actions/transfer.json)
|
||||
- **核心参数**: `resource`, `target_device`, `mount_resource`
|
||||
- **占位符字段**:
|
||||
- `resource` — **ResourceSlot**,待转移的物料数组 `[{id, name, uuid}, ...]`
|
||||
- `target_device` — **DeviceSlot**,目标设备路径字符串
|
||||
- `mount_resource` — **ResourceSlot**,目标孔位数组 `[{id, name, uuid}, ...]`
|
||||
|
||||
---
|
||||
|
||||
## 人工确认
|
||||
|
||||
### `manual_confirm`
|
||||
|
||||
创建人工确认节点,等待用户手动确认后继续(含物料转移上下文)
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
|
||||
- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids`
|
||||
- **占位符字段**:
|
||||
- `resource` — **ResourceSlot**,物料数组
|
||||
- `target_device` — **DeviceSlot**,目标设备路径
|
||||
- `mount_resource` — **ResourceSlot**,目标孔位数组
|
||||
- `assignee_user_ids` — `unilabos_manual_confirm` 类型
|
||||
270
.cursor/skills/virtual-workbench/actions/manual_confirm.json
Normal file
270
.cursor/skills/virtual-workbench/actions/manual_confirm.json
Normal file
@@ -0,0 +1,270 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"resource": "resource",
|
||||
"target_device": "target_device",
|
||||
"mount_resource": "mount_resource",
|
||||
"timeout_seconds": "timeout_seconds",
|
||||
"assignee_user_ids": "assignee_user_ids"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resource": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "resource"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"target_device": {
|
||||
"type": "string",
|
||||
"description": "device reference"
|
||||
},
|
||||
"mount_resource": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "mount_resource"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"assignee_user_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resource",
|
||||
"target_device",
|
||||
"mount_resource",
|
||||
"timeout_seconds",
|
||||
"assignee_user_ids"
|
||||
],
|
||||
"_unilabos_placeholder_info": {
|
||||
"resource": "unilabos_resources",
|
||||
"target_device": "unilabos_devices",
|
||||
"mount_resource": "unilabos_resources",
|
||||
"assignee_user_ids": "unilabos_manual_confirm"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"resource": "unilabos_resources",
|
||||
"target_device": "unilabos_devices",
|
||||
"mount_resource": "unilabos_resources",
|
||||
"assignee_user_ids": "unilabos_manual_confirm"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"material_number": "material_number"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"material_number": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"material_number"
|
||||
]
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
24
.cursor/skills/virtual-workbench/actions/move_to_output.json
Normal file
24
.cursor/skills/virtual-workbench/actions/move_to_output.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"station_id": "station_id",
|
||||
"material_number": "material_number"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"station_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"material_number": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"station_id",
|
||||
"material_number"
|
||||
]
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"count": "count"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"default": 5
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"goal_default": {
|
||||
"count": 5
|
||||
},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
24
.cursor/skills/virtual-workbench/actions/start_heating.json
Normal file
24
.cursor/skills/virtual-workbench/actions/start_heating.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"station_id": "station_id",
|
||||
"material_number": "material_number"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"station_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"material_number": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"station_id",
|
||||
"material_number"
|
||||
]
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
255
.cursor/skills/virtual-workbench/actions/transfer.json
Normal file
255
.cursor/skills/virtual-workbench/actions/transfer.json
Normal file
@@ -0,0 +1,255 @@
|
||||
{
|
||||
"type": "UniLabJsonCommandAsync",
|
||||
"goal": {
|
||||
"resource": "resource",
|
||||
"target_device": "target_device",
|
||||
"mount_resource": "mount_resource"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resource": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "resource"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"target_device": {
|
||||
"type": "string",
|
||||
"description": "device reference"
|
||||
},
|
||||
"mount_resource": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "mount_resource"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resource",
|
||||
"target_device",
|
||||
"mount_resource"
|
||||
],
|
||||
"_unilabos_placeholder_info": {
|
||||
"resource": "unilabos_resources",
|
||||
"target_device": "unilabos_devices",
|
||||
"mount_resource": "unilabos_resources"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"resource": "unilabos_resources",
|
||||
"target_device": "unilabos_devices",
|
||||
"mount_resource": "unilabos_resources"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user