From 861a012747ea98412664d109587c9e2a54b6ebc7 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:15:06 +0800 Subject: [PATCH 1/7] allow non @topic_config support --- unilabos/ros/nodes/base_device_node.py | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index ffc106c7..f8a19f98 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -1256,9 +1256,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): return self._lab_logger def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0): - """创建ROS发布者,仅当方法/属性有 @topic_config 装饰器时才创建。""" - # 检测 @topic_config 装饰器配置 - topic_config = {} + """创建ROS发布者。已在 status_types 中声明的属性直接创建;@topic_config 用于覆盖默认参数。""" + topic_cfg = {} driver_class = type(self.driver_instance) # 区分 @property 和普通方法两种情况 @@ -1267,23 +1266,17 @@ class BaseROS2DeviceNode(Node, Generic[T]): ) if is_prop: - # @property: 检测 fget 上的 @topic_config class_attr = getattr(driver_class, attr_name) if class_attr.fget is not None: - topic_config = get_topic_config(class_attr.fget) + topic_cfg = get_topic_config(class_attr.fget) else: - # 普通方法: 直接检测 attr_name 方法上的 @topic_config if hasattr(self.driver_instance, attr_name): method = getattr(self.driver_instance, attr_name) if callable(method): - topic_config = get_topic_config(method) - - # 没有 @topic_config 装饰器则跳过发布 - if not topic_config: - return + topic_cfg = get_topic_config(method) # 发布名称优先级: @topic_config(name=...) > get_ 前缀去除 > attr_name - cfg_name = topic_config.get("name") + cfg_name = topic_cfg.get("name") if cfg_name: publish_name = cfg_name elif attr_name.startswith("get_"): @@ -1291,10 +1284,10 @@ class BaseROS2DeviceNode(Node, Generic[T]): else: publish_name = attr_name - # 使用装饰器配置或默认值 - cfg_period = topic_config.get("period") - cfg_print = topic_config.get("print_publish") - cfg_qos = topic_config.get("qos") + # @topic_config 参数覆盖默认值 + cfg_period = topic_cfg.get("period") + cfg_print = topic_cfg.get("print_publish") + cfg_qos = topic_cfg.get("qos") period: float = cfg_period if cfg_period is not None else initial_period print_publish: bool = cfg_print if cfg_print is not None else self._print_publish qos: int = cfg_qos if cfg_qos is not None else 10 From 25c94af755ff438cfb9f9ec81ff56401902f0c80 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:01:22 +0800 Subject: [PATCH 2/7] add running status debounce --- unilabos/app/ws_client.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index a4fb6433..eed32e1b 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -1369,6 +1369,10 @@ class WebSocketClient(BaseCommunicationClient): self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager) self.queue_processor = QueueProcessor(self.device_manager, self.message_processor) + # running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)} + self._job_running_last_sent: Dict[str, tuple] = {} + self._job_running_debounce_interval: float = 10.0 # 秒 + # 设置相互引用 self.message_processor.set_queue_processor(self.queue_processor) self.message_processor.set_websocket_client(self) @@ -1468,22 +1472,32 @@ class WebSocketClient(BaseCommunicationClient): logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}") return + job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name) + # 拦截最终结果状态,与原版本逻辑一致 if status in ["success", "failed"]: + self._job_running_last_sent.pop(item.job_id, None) + host_node = HostNode.get_instance(0) if host_node: - # 从HostNode的device_action_status中移除job_id try: host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None) except (KeyError, AttributeError): logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status") - # logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}") - - # 通知队列处理器job完成(包括timeout的job) self.queue_processor.handle_job_completed(item.job_id, status) - # 发送job状态消息 + # running状态按job_id做debounce,内容变化时仍然上报 + if status == "running": + now = time.time() + cached = self._job_running_last_sent.get(item.job_id) + if cached is not None: + last_ts, last_data = cached + if now - last_ts < self._job_running_debounce_interval and last_data == feedback_data: + logger.trace(f"[WebSocketClient] Job status debounced (skip): {job_log} - {status}") + return + self._job_running_last_sent[item.job_id] = (now, feedback_data) + message = { "action": "job_status", "data": { @@ -1499,7 +1513,6 @@ class WebSocketClient(BaseCommunicationClient): } self.message_processor.send_message(message) - job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name) logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}") def send_ping(self, ping_id: str, timestamp: float) -> None: From 49b3c850f94870470ca8c69cb9a94522f4983038 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:01:23 +0800 Subject: [PATCH 3/7] fit cocurrent gap --- unilabos/app/ws_client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index eed32e1b..851ae320 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -1113,7 +1113,7 @@ class MessageProcessor: "task_id": task_id, "job_id": job_id, "free": free, - "need_more": need_more, + "need_more": need_more + 1, }, } @@ -1253,7 +1253,7 @@ class QueueProcessor: "task_id": job_info.task_id, "job_id": job_info.job_id, "free": False, - "need_more": 10, + "need_more": 10 + 1, }, } self.message_processor.send_message(message) @@ -1286,7 +1286,7 @@ class QueueProcessor: "task_id": job_info.task_id, "job_id": job_info.job_id, "free": False, - "need_more": 10, + "need_more": 10 + 1, }, } success = self.message_processor.send_message(message) From c91b600e907f2bb5a6121a2a1e68e53dc0e7117d Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:53:31 +0800 Subject: [PATCH 4/7] update handle creation api --- .cursor/skills/create-device-skill/SKILL.md | 24 +++++++++++++++------ unilabos/ros/nodes/presets/host_node.py | 1 + 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.cursor/skills/create-device-skill/SKILL.md b/.cursor/skills/create-device-skill/SKILL.md index c01a2e37..7361a09e 100644 --- a/.cursor/skills/create-device-skill/SKILL.md +++ b/.cursor/skills/create-device-skill/SKILL.md @@ -188,11 +188,21 @@ API 模板结构: - 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: "", node_template_name: ""} -# - #10 获取资源树 GET /lab/material/download/{lab_uuid} +# - #1 GET /edge/lab/info → 直接拿到 lab_uuid +# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户 +# - #3 创建节点 POST /edge/workflow/node +# body: {workflow_uuid, resource_template_name: "", node_template_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 +# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid} +# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action +# - #12 获取资源树 GET /lab/material/download/{lab_uuid} ## Placeholder Slot 填写规则 - unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"} @@ -209,7 +219,7 @@ API 模板结构: ### Step 5 — 验证 检查文件完整性: -- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#9 工作流/动作、#10 资源树) +- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树) - [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表 - [ ] `action-index.md` 列出所有 action 并有描述 - [ ] `actions/` 目录中每个 action 有对应 JSON 文件 @@ -310,7 +320,7 @@ API 模板结构: "container" ``` -### 通过 API #10 获取资源树 +### 通过 API #12 获取资源树 ```bash curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 2cac28f4..e5e212b1 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -1632,6 +1632,7 @@ class HostNode(BaseROS2DeviceNode): def manual_confirm(self, timeout_seconds: int, assignee_user_ids: list[str], **kwargs) -> dict: """ timeout_seconds: 超时时间(秒),默认3600秒 + 修改的结果无效,是只读的 """ return kwargs From 1d1c1367df1a8592c979fedb37e3ad1a36973451 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:15:38 +0800 Subject: [PATCH 5/7] scale multi exec thread up to 48 --- .cursor/skills/create-device-skill/SKILL.md | 4 +++- unilabos/app/web/client.py | 6 ++++-- unilabos/ros/main_slave_run.py | 5 +++-- unilabos/workflow/common.py | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.cursor/skills/create-device-skill/SKILL.md b/.cursor/skills/create-device-skill/SKILL.md index 7361a09e..6ce54450 100644 --- a/.cursor/skills/create-device-skill/SKILL.md +++ b/.cursor/skills/create-device-skill/SKILL.md @@ -203,6 +203,8 @@ API 模板结构: # - #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 ## Placeholder Slot 填写规则 - unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"} @@ -219,7 +221,7 @@ API 模板结构: ### Step 5 — 验证 检查文件完整性: -- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树) +- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情) - [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表 - [ ] `action-index.md` 列出所有 action 并有描述 - [ ] `actions/` 目录中每个 action 有对应 JSON 文件 diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index b1cc67eb..1dd056ae 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -80,19 +80,20 @@ class HTTPClient: f.write(json.dumps(payload, indent=4)) # 从序列化数据中提取所有节点的UUID(保存旧UUID) old_uuids = {n.res_content.uuid: n for n in resources.all_nodes} + nodes_info = [x for xs in resources.dump() for x in xs] if not self.initialized or first_add: self.initialized = True info(f"首次添加资源,当前远程地址: {self.remote_addr}") response = requests.post( f"{self.remote_addr}/edge/material", - json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, + json={"nodes": nodes_info, "mount_uuid": mount_uuid}, headers={"Authorization": f"Lab {self.auth}"}, timeout=60, ) else: response = requests.put( f"{self.remote_addr}/edge/material", - json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, + json={"nodes": nodes_info, "mount_uuid": mount_uuid}, headers={"Authorization": f"Lab {self.auth}"}, timeout=10, ) @@ -111,6 +112,7 @@ class HTTPClient: uuid_mapping[i["uuid"]] = i["cloud_uuid"] else: logger.error(f"添加物料失败: {response.text}") + logger.trace(f"添加物料失败: {nodes_info}") for u, n in old_uuids.items(): if u in uuid_mapping: n.res_content.uuid = uuid_mapping[u] diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py index c24f9e8e..7dca43e8 100644 --- a/unilabos/ros/main_slave_run.py +++ b/unilabos/ros/main_slave_run.py @@ -1,4 +1,5 @@ import json +import os # from nt import device_encoding import threading @@ -61,7 +62,7 @@ def main( rclpy.init(args=rclpy_init_args) else: logger.info("[ROS] rclpy already initialized, reusing context") - executor = rclpy.__executor = MultiThreadedExecutor() + executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48)) # 创建主机节点 host_node = HostNode( "host_node", @@ -122,7 +123,7 @@ def slave( rclpy.init(args=rclpy_init_args) executor = rclpy.__executor if not executor: - executor = rclpy.__executor = MultiThreadedExecutor() + executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48)) # 1.5 启动 executor 线程 thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread") diff --git a/unilabos/workflow/common.py b/unilabos/workflow/common.py index 3e2fec92..e0efad56 100644 --- a/unilabos/workflow/common.py +++ b/unilabos/workflow/common.py @@ -346,7 +346,7 @@ def refactor_data( "template_name": template_name, "resource_name": resource_name, "description": step.get("description", step.get("purpose", f"{operation} operation")), - "lab_node_type": "Device", + "lab_node_type": "ILab", "param": step.get("parameters", step.get("action_args", {})), "footer": f"{template_name}-{resource_name}", } From fbfc3e30fb9aa1c58218266bbe5ded938caef472 Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:40:31 +0800 Subject: [PATCH 6/7] update unilabos_formulation & batch-submit-exp --- .../skills/batch-submit-experiment/SKILL.md | 54 +++++++++++++------ .../scripts/gen_notebook_params.py | 1 + .cursor/skills/create-device-skill/SKILL.md | 41 +++++++++++++- 3 files changed, 79 insertions(+), 17 deletions(-) diff --git a/.cursor/skills/batch-submit-experiment/SKILL.md b/.cursor/skills/batch-submit-experiment/SKILL.md index 76e1ab1c..de6fed5e 100644 --- a/.cursor/skills/batch-submit-experiment/SKILL.md +++ b/.cursor/skills/batch-submit-experiment/SKILL.md @@ -1,6 +1,6 @@ --- name: batch-submit-experiment -description: Batch submit experiments (notebooks) to Uni-Lab platform — list workflows, generate node_params from registry schemas, submit multiple rounds. Use when the user wants to submit experiments, create notebooks, batch run workflows, or mentions 提交实验/批量实验/notebook/实验轮次. +description: Batch submit experiments (notebooks) to Uni-Lab platform — 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/实验轮次/实验状态. --- # 批量提交实验指南 @@ -59,7 +59,7 @@ AUTH="Authorization: Lab <上面命令输出的 token>" ### 4. workflow_uuid(目标工作流) -用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #2 列出可用 workflow 供选择。 +用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #3 列出可用 workflow 供选择。 **四项全部就绪后才可开始。** @@ -68,8 +68,9 @@ AUTH="Authorization: Lab <上面命令输出的 token>" 在整个对话过程中,agent 需要记住以下状态,避免重复询问用户: - `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**) +- `project_uuid` — 项目 UUID(通过 API #2 列出项目列表,**让用户选择**) - `workflow_uuid` — 工作流 UUID(用户提供或从列表选择) -- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #3 获取) +- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #4 获取) ## 请求约定 @@ -97,7 +98,17 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" 记住 `data.uuid` 为 `lab_uuid`。 -### 2. 列出可用 workflow +### 2. 列出实验室项目(让用户选择项目) + +```bash +curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH" +``` + +返回项目列表,展示给用户选择。列出每个项目的 `uuid` 和 `name`。 + +用户**必须**选择一个项目,记住 `project_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" @@ -105,7 +116,7 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid 返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid` 和 `name`。 -### 3. 获取 workflow 模板详情 +### 4. 获取 workflow 模板详情 ```bash curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH" @@ -119,7 +130,7 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$A > **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。 -### 4. 提交实验(创建 notebook) +### 5. 提交实验(创建 notebook) ```bash curl -s -X POST "$BASE/api/v1/lab/notebook" \ @@ -132,6 +143,7 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \ ```json { "lab_uuid": "", + "project_uuid": "", "workflow_uuid": "", "name": "<实验名称>", "node_params": [ @@ -159,6 +171,16 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \ > **注意**:`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 请求体详解 @@ -181,7 +203,7 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \ | 字段 | 类型 | 说明 | |------|------|------| -| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #3 获取) | +| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) | | `param` | object | 动作参数(根据本地注册表 schema 填写) | | `sample_params` | array | 样品相关参数(液体名、体积等) | @@ -222,7 +244,7 @@ python scripts/gen_notebook_params.py \ 如果脚本不可用或注册表不存在: -1. 调用 API #3 获取 workflow 详情 +1. 调用 API #4 获取 workflow 详情 2. 找到每个 action 节点的 `node_uuid` 3. 在本地注册表中查找对应设备的 `action_value_mappings`: ``` @@ -275,13 +297,15 @@ Task Progress: - [ ] Step 1: 确认 ak/sk → 生成 AUTH token - [ ] Step 2: 确认 --addr → 设置 BASE URL - [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid -- [ ] Step 4: 确认 workflow_uuid(用户提供或从 GET #2 列表选择) -- [ ] Step 5: GET workflow detail (#3) → 提取各节点 uuid、设备ID、动作名 -- [ ] Step 6: 定位本地注册表 req_device_registry_upload.json -- [ ] Step 7: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板 -- [ ] Step 8: 引导用户填写每轮的参数(sample_uuids、param、sample_params) -- [ ] Step 9: 构建完整请求体 → POST /lab/notebook 提交 -- [ ] Step 10: 检查返回结果,确认提交成功 +- [ ] 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 状态,确认已调度 ``` --- diff --git a/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py b/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py index 4b984851..f22b37e8 100644 --- a/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py +++ b/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py @@ -265,6 +265,7 @@ def generate_template(nodes, registry_index, rounds): return { "lab_uuid": "$TODO_LAB_UUID", + "project_uuid": "$TODO_PROJECT_UUID", "workflow_uuid": "$TODO_WORKFLOW_UUID", "name": "$TODO_EXPERIMENT_NAME", "node_params": node_params, diff --git a/.cursor/skills/create-device-skill/SKILL.md b/.cursor/skills/create-device-skill/SKILL.md index 6ce54450..20cd2f33 100644 --- a/.cursor/skills/create-device-skill/SKILL.md +++ b/.cursor/skills/create-device-skill/SKILL.md @@ -158,6 +158,7 @@ python ./scripts/extract_device_actions.py [--registry ] ./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)可填不存在的路径 @@ -211,6 +212,7 @@ API 模板结构: - 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 字段、类型及含义 @@ -222,7 +224,7 @@ API 模板结构: 检查文件完整性: - [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情) -- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表 +- [ ] `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` 字段 @@ -268,7 +270,7 @@ API 模板结构: ## Placeholder Slot 类型体系 -`placeholder_keys` / `_unilabos_placeholder_info` 中有 4 种值,对应不同的填写方式: +`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式: | placeholder 值 | Slot 类型 | 填写格式 | 选取范围 | |---------------|-----------|---------|---------| @@ -276,6 +278,7 @@ API 模板结构: | `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`) @@ -322,6 +325,40 @@ API 模板结构: "container" ``` +### FormulationSlot(`unilabos_formulation`) + +描述**液体配方**:向哪些物料容器中加入哪些液体及体积。填写为**对象数组**: + +```json +[ + { + "sample_uuid": "", + "well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1", + "liquids": [ + { "name": "LiPF6", "volume": 0.6 }, + { "name": "DMC", "volume": 1.2 } + ] + } +] +``` + +#### 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `sample_uuid` | string | 样品 UUID,无样品时传空字符串 `""` | +| `well_name` | string | 目标物料容器的 **name**(从资源树中取物料节点的 `name` 字段,如瓶子、孔位名称) | +| `liquids` | array | 要加入的液体列表 | +| `liquids[].name` | string | 液体名称(如试剂名、溶剂名) | +| `liquids[].volume` | number | 液体体积(单位由设备决定,通常为 mL) | + +#### 填写规则 + +- `well_name` 必须是资源树中已存在的物料节点 `name`(不是 `id` 路径),通过 API #12 获取资源树后筛选 +- 每个数组元素代表一个目标容器的配方 +- 一个容器可以加入多种液体(`liquids` 数组多条记录) +- 与 ResourceSlot 的区别:ResourceSlot 填 `{id, name, uuid}` 指向物料本身;FormulationSlot 用 `well_name` 引用物料,并附带液体配方信息 + ### 通过 API #12 获取资源树 ```bash From 58997f06549d0c3508b4870b26cb9dafe064f67e Mon Sep 17 00:00:00 2001 From: Xuwznln <18435084+Xuwznln@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:34:25 +0800 Subject: [PATCH 7/7] fix create_resource_with_slot --- unilabos/ros/nodes/base_device_node.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index f8a19f98..e249bc0f 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -486,18 +486,12 @@ class BaseROS2DeviceNode(Node, Generic[T]): if len(rts.root_nodes) == 1 and parent_resource is not None: plr_instance = plr_instances[0] if isinstance(plr_instance, Plate): - empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1: ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT) LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT) self.lab_logger().warning( f"增加液体资源时,数量为1,自动补全为 {len(LIQUID_INPUT_SLOT)} 个" ) - for liquid_type, liquid_volume, liquid_input_slot in zip( - ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT - ): - empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume) - plr_instance.set_well_liquids(empty_liquid_info_in) try: # noinspection PyProtectedMember keys = list(plr_instance._ordering.keys()) @@ -511,6 +505,10 @@ class BaseROS2DeviceNode(Node, Generic[T]): input_wells = [] for r in LIQUID_INPUT_SLOT: input_wells.append(plr_instance.children[r]) + for input_well, liquid_type, liquid_volume, liquid_input_slot in zip( + input_wells, ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT + ): + input_well.set_liquids([(liquid_type, liquid_volume, "uL")]) final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources( input_wells ).dump()