[
    {
        "id": "6bb10895ab509f5b",
        "type": "tab",
        "label": "视觉语言模型 (VLM)",
        "disabled": false,
        "info": "# 视觉语言模型 (Vision Language Model)\n\n## 功能介绍\n\n本章节介绍如何在RDK平台体验端侧 Vision Language Model (VLM)。得益于书生大模型、SmolVLM的优秀成果，我们在RDK平台上实现了量化与部署。同时，本示例基于 llama.cpp 中 KV Cache 的强大管理能力，结合 RDK 平台 BPU 模块的计算优势，实现了本地 VLM 模型部署。\n\n## ⚠️ 重要：ION内存配置（必须！）\n\n**这是最关键的一步！** 如果不配置ION内存，模型100%会因为内存不足崩溃（OOM Killed）。\n\n### 配置步骤：\n\n1. **运行配置工具：**\n   ```bash\n   sudo srpi-config\n   ```\n\n2. **设置ION内存：**\n   - 选择：`Performance Options` → `ION Memory`\n   - 选择：**`320MB+640MB+640MB`** (即 1.6GB)\n   - 确认保存\n\n3. **重启生效：**\n   ```bash\n   sudo reboot\n   ```\n   **必须重启！** 配置才会生效。\n\n4. **（可选）优化CPU性能：**\n   重启后可以设置CPU高性能模式，避免推理卡顿：\n   ```bash\n   sudo bash -c 'echo performance >/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor'\n   ```\n\n### 验证配置：\n重启后可以通过以下命令验证ION内存配置：\n```bash\ncat /proc/device-tree/reserved-memory/*/compatible\n```\n\n## 使用模式\n\n### 📷 拍照模式\n1. 选择模型类型（InternVL 或 SmolVLM）\n2. 点击\"📷 USB摄像头拍照\"按钮\n3. 系统会自动对照片进行VLM推理\n4. 推理结果（文本描述）会显示在Node-RED编辑器中\n\n### 🖼️ 回灌模式\n1. 选择模型类型（InternVL 或 SmolVLM）\n2. 点击\"启动本地图片VLM推理\"按钮\n3. 系统会对指定图片进行推理\n4. 推理结果（文本描述）会显示在Node-RED编辑器中\n\n## 支持平台\n\n- RDK X5, RDK X5 Module\n- RDK S100, RDK S100P\n\n## 支持模型\n\n### InternVL2_5 / InternVL3\n- 参数量：1B / 2B\n- 图像编码模型：vit_model_int16_*.bin (X5) / vit_model_int16_*.hbm (S100)\n- 文本编解码模型：Qwen2.5-0.5B-Instruct-Q4_0.gguf / qwen2_5_*.gguf\n\n### SmolVLM2\n- 参数量：256M / 500M\n- 图像编码模型：SigLip_int16_SmolVLM2_*.bin (X5) / SigLip_int16_SmolVLM2_*.hbm (S100)\n- 文本编解码模型：SmolVLM2-*-Video-Instruct-Q8_0.gguf\n\n## 算法信息\n\n| 模型 | 参数量 | 量化方式 | 平台 | 输入尺寸 | image encoder time(ms) | prefill eval time(ms/token) | eval time(ms/token) |\n|------|--------|---------|------|---------|----------------------|---------------------------|---------------------|\n| InternVL2_5 | 0.5B | Q4_0 | X5 | 1x3x448x448 | 2456.00 | 7.7 | 51.6 |\n| InternVL3 | 0.5B | Q8_0 | S100 | 1x3x448x448 | 100.00 | 9.19 | 41.65 |\n| Smolvlm2 | 256M | Q8_0 | X5 | 1x3x512x512 | 1053 | 9.3 | 27.8 |\n\n## 📋 使用前必读\n\n> ⚠️ **重要提示**：使用本功能前，请务必先配置开发板！\n> \n> 建议参考官方文档进行详细配置：\n> \n> 🔗 [hobot_llamacpp 官方文档](https://developer.d-robotics.cc/rdk_doc/rdk_s/Robot_development/boxs/generate/hobot_llamacpp)\n> \n> 配置完成后，请按照下方步骤进行ION内存配置。\n\n## 准备工作\n\n1. ✅ **配置ION内存**（见上方重要提示）\n2. RDK已烧录好Ubuntu 22.04系统镜像\n3. RDK已成功安装TogetheROS.Bot\n4. 下载安装功能包：`sudo apt install tros-humble-hobot-llamacpp`\n5. 模型文件会自动下载到 `$HOME/vlm_model` 目录（安装时自动完成）\n\n## 模型文件位置\n\n- **模型目录：** `$HOME/vlm_model/`（持久化目录，重启后不会丢失）\n- **图像编码模型：** 根据平台自动下载（X5: `vit_model_int16_v2.bin`, S100: `vit_model_int16.hbm`）\n- **文本编解码模型：** `Qwen2.5-0.5B-Instruct-Q4_0.gguf` (InternVL) 或 `SmolVLM2-256M-Video-Instruct-Q8_0.gguf` (SmolVLM)\n\n## 结果获取方式\n\n### 方式A：ROS2 Topic订阅（推荐）\n\nVLM推理完成后，结果会发布到ROS2 Topic：\n- **Topic名称：** `/tts_text`\n- **消息类型：** `std_msgs/msg/String`\n- **内容：** 推理结果文本（如\"这张图片展示了一只大熊猫...\"）\n\n当前流程已自动订阅该Topic并解析结果。\n\n### 方式B：从命令输出解析（备选）\n\n如果Topic订阅失败，流程会自动从命令的标准输出中解析结果。\n\n## 注意事项\n\n- ⚠️ **必须配置ION内存**（1.6GB），否则会因内存不足崩溃\n- 推理过程需要较长时间（可能需要10-30秒），请耐心等待完整输出\n- VLM输出是流式的，结果会逐步显示，请等待推理完成\n- 模型文件使用绝对路径，确保ROS2命令能正确找到\n- 如果只看到版本信息，说明推理还在进行中，请继续等待\n- 模型文件保存在 `$HOME/vlm_model`，重启后不会丢失",
        "env": []
    },
    {
        "id": "0ecda04649224fbc",
        "type": "comment",
        "z": "6bb10895ab509f5b",
        "name": "⚠️ 使用前必读",
        "info": "**重要提示**：使用本功能，如果发现使用失败，请按以下方式配置开发板！\n参考官方文档进行详细配置：\n🔗 https://developer.d-robotics.cc/rdk_doc/rdk_s/Robot_development/boxs/generate/hobot_llamacpp\n\n配置完成后，请确保已完成ION内存配置（见上方网页说明）。",
        "x": 150,
        "y": 20,
        "wires": []
    },
    {
        "id": "fabf5da9a5983e8b",
        "type": "comment",
        "z": "6bb10895ab509f5b",
        "name": "🔧 模型选择",
        "info": "选择要使用的VLM模型类型",
        "x": 110,
        "y": 80,
        "wires": []
    },
    {
        "id": "15f6873897128cc8",
        "type": "inject",
        "z": "6bb10895ab509f5b",
        "name": "选择 InternVL",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": true,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "internvl",
        "payloadType": "str",
        "x": 140,
        "y": 120,
        "wires": [
            [
                "bdc27d0340743b9f"
            ]
        ]
    },
    {
        "id": "7dbc19cdb30154ad",
        "type": "inject",
        "z": "6bb10895ab509f5b",
        "name": "选择 SmolVLM",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "smolvlm",
        "payloadType": "str",
        "x": 140,
        "y": 160,
        "wires": [
            [
                "bdc27d0340743b9f"
            ]
        ]
    },
    {
        "id": "bdc27d0340743b9f",
        "type": "function",
        "z": "6bb10895ab509f5b",
        "name": "保存模型类型",
        "func": "// 保存模型类型到全局变量\n// 改进：添加平台检测逻辑，统一在JS中处理\nconst modelType = msg.payload || 'internvl';\nif (typeof global.vlmModelType === 'undefined') {\n    global.vlmModelType = {};\n}\nglobal.vlmModelType.current = modelType;\n\n// 检测平台（统一在JS中处理，避免Shell脚本重复逻辑）\n// 注意：Node-RED function节点中process对象不可用，使用默认值\n// Shell脚本会在运行时检测实际平台并覆盖\nlet platform = 'X5'; // 默认平台\nif (typeof global.vlmPlatform === 'undefined') {\n    // 默认X5，Shell脚本会在运行时检测实际平台\n    platform = 'X5';\n    global.vlmPlatform = platform;\n} else {\n    platform = global.vlmPlatform;\n}\n\nnode.status({ fill: 'green', shape: 'dot', text: '模型: ' + (modelType === 'internvl' ? 'InternVL' : 'SmolVLM') + ' (' + platform + ')' });\n\n// 根据模型类型和平台设置默认参数\nif (modelType === 'internvl') {\n    // InternVL 配置\n    if (platform === 'S100') {\n        global.vlmModelType.modelFile = 'vit_model_int16.hbm';\n    } else {\n        global.vlmModelType.modelFile = 'vit_model_int16_v2.bin'; // X5平台默认\n    }\n    global.vlmModelType.llmModel = 'Qwen2.5-0.5B-Instruct-Q4_0.gguf';\n    global.vlmModelType.modelTypeParam = ''; // InternVL不需要model_type参数\n} else if (modelType === 'smolvlm') {\n    // SmolVLM 配置\n    if (platform === 'S100') {\n        global.vlmModelType.modelFile = 'SigLip_int16_SmolVLM2_256M_Instruct_S100.hbm';\n    } else {\n        global.vlmModelType.modelFile = 'SigLip_int16_SmolVLM2_256M_Instruct_MLP_C1_UP_X5.bin'; // X5平台默认\n    }\n    global.vlmModelType.llmModel = 'SmolVLM2-256M-Video-Instruct-Q8_0.gguf';\n    global.vlmModelType.modelTypeParam = '-p model_type:=1'; // SmolVLM需要model_type参数\n}\n\n// 确保提示词已初始化\nif (typeof global.vlmPrompt === 'undefined' || !global.vlmPrompt.current) {\n    global.vlmPrompt = {};\n    global.vlmPrompt.current = '描述一下这张图片.';\n}\nreturn null;",
        "outputs": 1,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 350,
        "y": 120,
        "wires": [
            []
        ]
    },
    {
        "id": "50e812054b358975",
        "type": "comment",
        "z": "6bb10895ab509f5b",
        "name": "📷 拍照推理流程",
        "info": "点击拍照按钮，系统会自动进行VLM推理并显示文本结果",
        "x": 150,
        "y": 300,
        "wires": []
    },
    {
        "id": "0187a613a81ee6e0",
        "type": "inject",
        "z": "6bb10895ab509f5b",
        "name": "📷 USB摄像头拍照",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 150,
        "y": 260,
        "wires": [
            [
                "31890f0b1541ca6f"
            ]
        ]
    },
    {
        "id": "31890f0b1541ca6f",
        "type": "rdk-camera takephoto",
        "z": "6bb10895ab509f5b",
        "cameratype": "1",
        "filemode": "2",
        "filename": "photo.jpg",
        "filedefpath": "0",
        "filepath": "/home/sunrise/vlm_model",
        "fileformat": "jpeg",
        "resolution": "2",
        "rotation": "0",
        "fliph": "0",
        "flipv": "0",
        "brightness": "50",
        "contrast": "0",
        "sharpness": "0",
        "quality": "80",
        "imageeffect": "none",
        "exposuremode": "auto",
        "iso": "0",
        "agcwait": "1.0",
        "led": "0",
        "awb": "auto",
        "name": "拍照",
        "x": 350,
        "y": 280,
        "wires": [
            [
                "12caf6dbd79f0705",
                "b3d3d9ec437e6bac"
            ]
        ]
    },
    {
        "id": "12caf6dbd79f0705",
        "type": "function",
        "z": "6bb10895ab509f5b",
        "name": "准备VLM推理",
        "func": "// 准备VLM推理：处理图片路径并构建命令\n// 使用前推荐进行设置，参考：https://developer.d-robotics.cc/rdk_doc/rdk_s/Robot_development/boxs/generate/hobot_llamacpp\nconst self = node;\n\n// 读取提示词并保存到消息对象\nmsg.vlmPrompt = global.get('vlmPrompt') || \"描述一下这张图片.\";\n\n// 获取并处理图片路径\nvar imagePath = msg.payload;\nif (typeof imagePath === 'string') {\n    imagePath = imagePath.trim();\n    if (imagePath.startsWith('~')) {\n        imagePath = imagePath.replace(/^~/, '/home/sunrise');\n    }\n    if (!imagePath.startsWith('/')) {\n        imagePath = '/home/sunrise/vlm_model/' + imagePath;\n    }\n} else {\n    imagePath = '/home/sunrise/vlm_model/photo.jpg';\n}\n\n// 确保目录存在\nconst imageDir = path.dirname(imagePath);\nif (!fs.existsSync(imageDir)) {\n    try {\n        fs.mkdirSync(imageDir, { recursive: true });\n    } catch (e) {\n        // 忽略错误\n    }\n}\n\n// 等待文件保存完成（异步处理）\nconst finalImagePath = imagePath;\nif (fs.existsSync(finalImagePath)) {\n    self.status({ fill: 'green', shape: 'dot', text: '✓ 图片已就绪，准备启动推理...' });\n    buildVlmCommand(finalImagePath);\n} else {\n    self.status({ fill: 'yellow', shape: 'dot', text: '📷 等待图片保存完成...' });\n    let retryCount = 0;\n    const maxRetries = 6;\n    const checkFile = function() {\n        if (fs.existsSync(finalImagePath)) {\n            self.status({ fill: 'green', shape: 'dot', text: '✓ 图片已就绪，准备启动推理...' });\n            buildVlmCommand(finalImagePath);\n        } else if (retryCount < maxRetries) {\n            retryCount++;\n            self.status({ fill: 'yellow', shape: 'dot', text: '📷 等待图片保存完成... (' + retryCount + '/' + maxRetries + ')' });\n            setTimeout(checkFile, 500);\n        } else {\n            // 超时后尝试查找最新图片\n            const dir = path.dirname(finalImagePath);\n            if (fs.existsSync(dir)) {\n                try {\n                    const files = fs.readdirSync(dir)\n                        .filter(file => file.toLowerCase().endsWith('.jpg') || file.toLowerCase().endsWith('.jpeg'))\n                        .map(file => {\n                            try {\n                                return { path: path.join(dir, file), mtime: fs.statSync(path.join(dir, file)).mtime };\n                            } catch (e) {\n                                return null;\n                            }\n                        })\n                        .filter(f => f !== null)\n                        .sort((a, b) => b.mtime - a.mtime);\n                    if (files.length > 0) {\n                        buildVlmCommand(files[0].path);\n                    } else {\n                        self.status({ fill: 'red', shape: 'dot', text: '找不到图片文件' });\n                    }\n                } catch (e) {\n                    self.status({ fill: 'red', shape: 'dot', text: '查找文件失败' });\n                }\n            } else {\n                self.status({ fill: 'red', shape: 'dot', text: '目录不存在' });\n            }\n        }\n    };\n    setTimeout(checkFile, 500);\n    return null;\n}\n\n// 构建VLM命令\nfunction buildVlmCommand(imagePath) {\n    // 读取提示词\n    var prompt = msg.vlmPrompt || global.get('vlmPrompt') || \"描述一下这张图片.\";\n    \n    // 读取模型配置\n    const modelType = (global.vlmModelType && global.vlmModelType.current) || 'internvl';\n    const llmModel = (global.vlmModelType && global.vlmModelType.llmModel) || 'Qwen2.5-0.5B-Instruct-Q4_0.gguf';\n    const modelTypeParam = (global.vlmModelType && global.vlmModelType.modelTypeParam) || '';\n    \n    // 转义提示词\n    var escapedPrompt = prompt.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"');\n    var llmPath = \"/home/sunrise/vlm_model/\" + llmModel;\n    \n    // 构建命令（包含运行时平台检测）\n    var cmd = 'source /opt/tros/humble/setup.bash && ';\n    cmd += 'PLATFORM=$(cat /proc/device-tree/model 2>/dev/null | strings | grep -oE \"(X5|S100)\" | head -1 || echo \"X5\") && ';\n    cmd += 'if [ \"$PLATFORM\" = \"S100\" ]; then MODEL_FILE=\"vit_model_int16.hbm\"; else MODEL_FILE=\"vit_model_int16_v2.bin\"; fi && ';\n    cmd += 'MODEL_PATH=\"/home/sunrise/vlm_model/$MODEL_FILE\" && ';\n    cmd += 'ros2 run hobot_llamacpp hobot_llamacpp --ros-args ';\n    cmd += '-p feed_type:=0 -p image_type:=0 ';\n    cmd += '-p image:=' + imagePath + ' ';\n    cmd += '-p user_prompt:=\"' + escapedPrompt + '\" ';\n    cmd += '-p model_file_name:=$MODEL_PATH ';\n    cmd += '-p llm_model_name:=' + llmPath;\n    \n    if (modelTypeParam) {\n        cmd += ' ' + modelTypeParam;\n    }\n    \n    // 输出命令\n    const newMsg = {\n        payload: cmd,\n        imagePath: imagePath,\n        _msgid: msg._msgid\n    };\n    \n    self.status({ fill: 'blue', shape: 'dot', text: '🚀 正在启动VLM推理引擎...' });\n    self.send(newMsg);\n}",
        "outputs": 1,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [
            {
                "var": "fs",
                "module": "fs"
            },
            {
                "var": "path",
                "module": "path"
            }
        ],
        "x": 550,
        "y": 280,
        "wires": [
            [
                "8e526f93113c5043"
            ]
        ]
    },
    {
        "id": "8e526f93113c5043",
        "type": "exec",
        "z": "6bb10895ab509f5b",
        "command": "",
        "addpay": true,
        "append": "",
        "useSpawn": true,
        "timer": "600",
        "oldrc": false,
        "name": "启动VLM推理",
        "x": 380,
        "y": 200,
        "wires": [
            [
                "0a8b9dbe09b6c145"
            ],
            [],
            []
        ]
    },
    {
        "id": "0a8b9dbe09b6c145",
        "type": "function",
        "z": "6bb10895ab509f5b",
        "name": "解析结果（后台）",
        "func": "// 解析VLM推理结果（从stdout解析，作为Topic的备选方案）\n// 优先使用ROS Topic获取结果，如果Topic已有结果，则跳过stdout解析\n\nconst msgId = msg._msgid || 'default';\n\n// 检查是否已经有Topic结果（优先级最高）\n// 如果Topic已经成功获取结果，直接使用Topic结果，跳过stdout解析\nif (msg.fromTopic === true && msg.result) {\n    // Topic已成功获取结果，不需要解析stdout\n    node.status({ fill: 'green', shape: 'dot', text: '已使用Topic结果，跳过stdout解析' });\n    // 清除缓冲区\n    if (typeof global.vlmOutputBuffer !== 'undefined' && global.vlmOutputBuffer[msgId]) {\n        delete global.vlmOutputBuffer[msgId];\n    }\n    return null;\n}\n\n// 检查Topic是否已经尝试获取结果\nif (typeof global.vlmTopicSubscribed !== 'undefined' && global.vlmTopicSubscribed[msgId]) {\n    // Topic已尝试获取结果，如果msg.fromTopic为true，说明Topic已成功获取结果，跳过stdout解析\n    if (msg.fromTopic === true) {\n        // Topic已成功获取结果，不需要解析stdout\n        node.status({ fill: 'blue', shape: 'dot', text: '已使用Topic结果，跳过stdout解析' });\n        return null;\n    }\n    // 如果Topic尝试了但没有结果，继续使用stdout解析作为备选\n}\n\n// 初始化全局缓冲区\nif (typeof global.vlmOutputBuffer === 'undefined') {\n    global.vlmOutputBuffer = {};\n}\n\nif (!global.vlmOutputBuffer[msgId]) {\n    global.vlmOutputBuffer[msgId] = '';\n}\n\n// 处理buffer格式的输出\nlet chunk = '';\nif (Buffer.isBuffer(msg.payload)) {\n    // 尝试UTF-8解码\n    try {\n        chunk = msg.payload.toString('utf8');\n    } catch (e) {\n        // 如果UTF-8失败，尝试其他编码\n        try {\n            chunk = msg.payload.toString('latin1');\n        } catch (e2) {\n            chunk = msg.payload.toString();\n        }\n    }\n} else if (typeof msg.payload === 'string') {\n    chunk = msg.payload;\n} else {\n    chunk = String(msg.payload || '');\n}\n\n// 累积输出\nif (chunk) {\n    global.vlmOutputBuffer[msgId] += chunk;\n}\n\nconst output = global.vlmOutputBuffer[msgId];\n\n// 检查输出是否被截断（只包含启动信息但没有推理结果）\nconst hasStartMessage = output.includes('开始VLM推理') || output.includes('可能需要10-30秒');\nconst hasEndMessage = output.includes('===') && output.split('===').length > 2;\n\n// 如果输出太短，可能还在处理中，等待更多数据\nif (!output || output.trim().length < 30) {\n    node.status({ fill: 'yellow', shape: 'dot', text: '⏳ VLM正在启动，请稍候...' });\n    return null;\n}\n\n// 如果输出只包含启动信息但没有后续内容，可能输出被截断\nif (hasStartMessage && !hasEndMessage && output.length < 500) {\n    // 检查是否包含ros2命令的输出\n    const hasRos2Output = output.includes('ros2') || output.includes('hobot_llamacpp') || output.includes('llamacpp_node');\n    if (!hasRos2Output) {\n        // 可能输出被截断，等待更多数据\n        node.status({ fill: 'yellow', shape: 'dot', text: '⏳ VLM正在加载模型，请稍候...' });\n        return null;\n    }\n}\n\n// 查找推理结果\n// VLM的输出格式：推理结果通常在最后，可能被日志信息包围\nconst lines = output.split(/[\\r\\n]+/).filter(line => line.trim().length > 0);\n\n// 排除的日志关键词（包括启动信息和echo输出）\nconst logKeywords = [\n    'INFO', 'WARN', 'ERROR', 'DEBUG', 'TRACE',\n    '===', '工作目录', '图片文件', '模型类型', '提示词', '检查', '启动',\n    'ros2', 'source', 'cd', 'cp', 'echo', 'ls',\n    'llamacpp_node', 'This is llama',\n    '[DNN]', 'HBRT', 'version', '3.7.3',\n    'image encoder', 'prefill', 'eval time',\n    '开始', '启动', '完成', '结束',\n    'bash', 'setup.bash', 'hobot_llamacpp',\n    '[UCPT]', 'log level', 'UCPT', // 添加UCPT日志过滤\n    'wget', '下载', 'Download', '进度', 'progress', // 添加下载相关过滤\n    '平台要求', '⚠', '检测到平台', '设备信息', // 添加平台检测相关过滤\n    '模型文件检查', '启动VLM推理', '提示词:', '图片文件:', // 添加状态信息过滤\n    '模型文件不存在', '开始自动下载', '下载完成', '下载失败', // 添加下载状态过滤\n    '平台检测', '设备信息', '检测到平台', // 添加平台检测过滤\n    '检查模型文件', '模型文件检查通过', '启动VLM推理', // 添加启动流程过滤\n    '可能需要', '请耐心等待', '开始VLM推理', // 添加提示信息过滤\n    '可能需要10-30秒', '请耐心等待', '开始VLM推理（可能需要', // 添加启动提示过滤\n    '/tmp/', '/opt/', '/dev/', 'image-', 'photo.jpg', // 添加路径和文件名过滤\n    'hbUCPMallocMem', 'hb_mem_alloc', 'hb_mem', 'MallocMem failed', 'allocate', 'allocation failed', 'ret: -400001', 'ret: -16777211', 'ION_ALLOCATOR', 'Fail to allocate', 'Insufficient memory', // 添加BPU内存错误过滤\n    '[BPU_PLAT]', 'BPU_PLAT', 'BPU Platform', 'Platform Version', 'BPU Platform Version' // 添加BPU平台版本信息过滤 // 添加BPU内存错误过滤\n];\n\n// 启动信息和echo输出模式（这些不应该被识别为结果）\n// 特别注意：严格过滤所有echo输出，包括图片文件提示、提示词提示等\nconst startInfoPatterns = [\n    /可能需要.*秒.*请耐心等待/i,\n    /开始VLM推理.*可能需要/i,\n    /===.*开始.*推理.*===/i,\n    /^===.*开始/i,\n    /.*可能需要.*秒.*请耐心等待.*===/i,\n    /图片文件:.*/i, // echo输出：图片文件路径提示\n    /提示词:.*/i, // echo输出：提示词提示\n    /^图片文件:/i, // 以图片文件开头的行（严格匹配）\n    /^提示词:/i, // 以提示词开头的行（严格匹配）\n    /^模型文件:/i, // 以模型文件开头的行\n    /^设备信息:/i, // 以设备信息开头的行\n    /^平台:/i, // 以平台开头的行\n    /^工作目录:/i, // 以工作目录开头的行\n    /^检测到平台:/i, // 以检测到平台开头的行\n    /.*\\/tmp\\/.*\\.(jpg|jpeg|png)/i, // 包含/tmp/路径的图片文件\n    /.*image-\\d{8}-\\d{6}\\.(jpg|jpeg)/i, // 时间戳格式的图片文件名\n    /^echo.*图片文件/i, // echo命令输出\n    /^echo.*提示词/i // echo命令输出\n];\n\nlet result = '';\n\n// 首先检查Topic是否已经有结果（优先级最高）\n// 如果Topic已经有结果，即使stdout中有错误，也优先使用Topic结果\nif (typeof global.vlmTopicSubscribed !== 'undefined' && global.vlmTopicSubscribed[msgId]) {\n    // Topic已尝试获取结果，检查是否有Topic结果\n    if (msg.fromTopic === true && msg.result) {\n        // Topic已成功获取结果，直接返回Topic结果，忽略stdout中的任何错误\n        node.status({ fill: 'green', shape: 'dot', text: '使用Topic结果，忽略stdout错误' });\n        return msg;\n    }\n}\n\n// 检查是否是错误情况（模型文件不存在、BPU内存分配失败等）\nconst errorKeywords = ['[错误]', '错误', 'Error', 'ERROR', 'fail', 'Fail', '不存在', 'not exists', 'Can not open'];\n// BPU相关错误关键词\nconst bpuErrorKeywords = ['hbUCPMallocMem', 'hb_mem_alloc', 'hb_mem', 'MallocMem failed', 'allocate', 'allocation failed', 'ret: -400001', 'ret: -16777211', 'ION_ALLOCATOR', 'Fail to allocate', 'Insufficient memory'];\nlet isError = false;\nlet errorLines = [];\nlet errorType = '';\n\n// 检查BPU内存分配错误\nfor (const line of lines) {\n    const trimmed = line.trim();\n    for (const keyword of bpuErrorKeywords) {\n        if (trimmed.includes(keyword)) {\n            isError = true;\n            errorType = 'bpu_memory_error';\n            errorLines.push(trimmed);\n            break;\n        }\n    }\n}\n\n// 如果检测到BPU错误，先检查Topic是否可能有结果（优先使用Topic结果）\nif (isError && errorType === 'bpu_memory_error') {\n    // 检查Topic是否已经尝试获取结果\n    const topicTried = typeof global.vlmTopicSubscribed !== 'undefined' && global.vlmTopicSubscribed[msgId];\n    \n    // 如果Topic还没有尝试，等待Topic结果（最多等待30秒）\n    if (!topicTried) {\n        // Topic还没有尝试，可能还在等待Topic结果，暂时不返回错误，继续等待\n        // 检查输出长度，如果输出很长但还没有Topic结果，可能需要更长时间\n        const waitTime = output.length > 10000 ? 30000 : 15000; // 输出长则等待更久（最多30秒）\n        \n        // 计算已等待时间（如果msg.startTime存在则使用，否则使用当前时间）\n        const startTime = msg.startTime || Date.now();\n        const elapsedTime = Date.now() - startTime;\n        \n        if (elapsedTime < waitTime) {\n            node.status({ fill: 'yellow', shape: 'dot', text: '检测到BPU错误，等待Topic结果... (' + Math.round(elapsedTime/1000) + 's/' + Math.round(waitTime/1000) + 's)' });\n            return null;\n        } else {\n            // 等待超时，但Topic还没尝试，可能是Topic订阅有问题，继续等待（不返回错误）\n            node.warn('BPU错误检测到，但Topic订阅可能未启动，继续等待...');\n            node.status({ fill: 'yellow', shape: 'dot', text: 'BPU错误，等待Topic结果（已等待' + Math.round(elapsedTime/1000) + 's）...' });\n            return null;\n        }\n    }\n    \n    // Topic已尝试，检查是否有Topic结果\n    // 如果msg.fromTopic为true，说明Topic已成功获取结果，优先使用Topic结果\n    if (msg.fromTopic === true && msg.result) {\n        // Topic已成功获取结果，即使有BPU错误也优先使用Topic结果\n        node.status({ fill: 'green', shape: 'dot', text: '使用Topic结果（忽略BPU错误）' });\n        // 清除缓冲区\n        cleanupGlobalVars(msgId);\n        return msg;\n    }\n    \n    // Topic已尝试但没有结果，返回BPU错误\n    const errorMessage = errorLines.join(' ');\n    msg.isError = true;\n    msg.errorType = 'bpu_memory_error';\n    msg.errorMessage = 'BPU内存分配失败: ' + errorMessage + '\\n\\n可能原因：\\n1. BPU内存不足，请关闭其他占用BPU的应用\\n2. 模型文件过大，超出设备内存限制\\n3. 设备资源不足，请检查系统资源使用情况\\n\\n注意：Topic未获取到结果，可能推理未完成';\n    node.status({ fill: 'red', shape: 'dot', text: 'BPU内存分配失败' });\n    msg.result = errorMessage;\n    msg.fullOutput = output.substring(Math.max(0, output.length - 2000));\n    \n    // 清除缓冲区\n    cleanupGlobalVars(msgId);\n    \n    return msg;\n}\n\n// 检查其他错误\nfor (const line of lines) {\n    const trimmed = line.trim();\n    for (const keyword of errorKeywords) {\n        if (trimmed.includes(keyword)) {\n            isError = true;\n            if (!errorType) {\n                errorType = 'other_error';\n            }\n            errorLines.push(trimmed);\n            break;\n        }\n    }\n}\n\n// 如果是错误情况，提取完整的错误信息\nif (isError && errorLines.length > 0) {\n    // 提取错误相关的所有行（包括错误信息和下载命令）\n    let errorStartIndex = -1;\n    for (let i = 0; i < lines.length; i++) {\n        const trimmed = lines[i].trim();\n        if (trimmed.includes('[错误]') || trimmed.includes('错误') || trimmed.includes('Error')) {\n            errorStartIndex = i;\n            break;\n        }\n    }\n    \n    if (errorStartIndex >= 0) {\n        // 提取从错误开始到文件末尾的所有相关行\n        const errorSection = lines.slice(errorStartIndex).filter(line => {\n            const trimmed = line.trim();\n            return trimmed.length > 0 && (\n                trimmed.includes('[错误]') ||\n                trimmed.includes('错误') ||\n                trimmed.includes('请先') ||\n                trimmed.includes('下载') ||\n                trimmed.includes('wget') ||\n                trimmed.includes('平台')\n            );\n        });\n        \n        if (errorSection.length > 0) {\n            result = errorSection.join('\\n');\n            msg.isError = true;\n            msg.errorType = 'model_file_missing';\n            node.status({ fill: 'red', shape: 'dot', text: '模型文件不存在' });\n            msg.result = result;\n            msg.fullOutput = output.substring(Math.max(0, output.length - 2000));\n            \n            // 清除缓冲区\n            if (global.vlmOutputBuffer && global.vlmOutputBuffer[msgId]) {\n                delete global.vlmOutputBuffer[msgId];\n            }\n            \n            return msg;\n        }\n    }\n}\n\n// 策略1：查找[WARN] [llama_cpp_node]之后的推理结果（这是VLM的标准输出格式）\n// 格式：[WARN] [timestamp] [llama_cpp_node]:\n//       推理结果文本（可能跨多行）\nlet warnIndex = -1;\nfor (let i = lines.length - 1; i >= 0; i--) {\n    const line = lines[i].trim();\n    // 查找包含[WARN]和[llama_cpp_node]的行\n    if (line.includes('[WARN]') && line.includes('[llama_cpp_node]')) {\n        warnIndex = i;\n        break;\n    }\n}\n\nif (warnIndex >= 0) {\n    // 从WARN行之后开始查找推理结果\n    let resultLines = [];\n    for (let i = warnIndex + 1; i < lines.length; i++) {\n        const line = lines[i].trim();\n        \n        // 跳过空行\n        if (line.length === 0) continue;\n        \n        // 如果遇到下一个日志标记（如[INFO], [WARN], [ERROR]），停止\n        if (line.match(/^\\[INFO\\]|^\\[WARN\\]|^\\[ERROR\\]|^\\[DEBUG\\]/)) {\n            break;\n        }\n        \n        // 跳过明显的日志行（但不跳过WARN行本身，因为结果在它后面）\n        let isLogLine = false;\n        for (const keyword of logKeywords) {\n            // 对于WARN之后的文本，更宽松地判断\n            if (keyword !== 'WARN' && line.includes(keyword)) {\n                isLogLine = true;\n                break;\n            }\n        }\n        \n        // 跳过时间戳、进度条等\n        if (line.match(/^\\d+\\.\\d+.*$/)) continue; // 时间戳\n        if (line.match(/^\\s*\\d+[KM]\\s+[\\.]+\\s+\\d+%\\s+\\d+[KM]\\s+\\d+[ms]/)) continue; // wget进度\n        if (line.match(/\\[UCPT\\]/i) || line.match(/UCPT.*log/i) || line.match(/log level/i)) continue;\n        \n        // 检查是否是BPU错误（不应该被识别为结果）\n        let isBpuError = false;\n        for (const keyword of bpuErrorKeywords) {\n            if (line.includes(keyword)) {\n                isBpuError = true;\n                break;\n            }\n        }\n        \n        // 如果包含中文或较长的英文，且不是日志行或BPU错误，收集为结果的一部分\n        const hasChinese = /[\\u4e00-\\u9fa5]/.test(line);\n        const hasEnglish = /[a-zA-Z]{4,}/.test(line);\n        \n        if (!isLogLine && !isBpuError && (hasChinese || (hasEnglish && line.length > 10))) {\n            resultLines.push(line);\n        }\n    }\n    \n    if (resultLines.length > 0) {\n        // 合并多行结果，清理</s>等标记\n        result = resultLines.join(' ').replace(/<\\/s>/g, '').trim();\n        // 如果结果长度足够，立即返回，跳过后续策略\n        if (result.length >= 10) {\n            // 清理结果：去除多余的空格和换行\n            result = result.replace(/\\s+/g, ' ').trim();\n            \n            // 找到有效结果，保存并清除缓冲区\n            msg.result = result;\n            msg.fullOutput = output.substring(Math.max(0, output.length - 2000));\n            \n            // 立即清除缓冲区（找到结果后立即清除，避免内存泄漏）\n            if (global.vlmOutputBuffer && global.vlmOutputBuffer[msgId]) {\n                delete global.vlmOutputBuffer[msgId];\n            }\n            \n            node.status({ fill: 'green', shape: 'dot', text: '✅ 推理完成！正在解析结果...' });\n            return msg;\n        }\n    }\n}\n\n// 如果策略1没找到结果，使用原来的策略1（从后往前查找）\nif (!result || result.length < 10) {\n    for (let i = lines.length - 1; i >= 0; i--) {\n        const line = lines[i].trim();\n        if (line.length < 10) continue;\n        \n        // 检查是否是日志行\n        let isLogLine = false;\n        for (const keyword of logKeywords) {\n            if (line.includes(keyword)) {\n                isLogLine = true;\n                break;\n            }\n        }\n        \n        // 跳过明显的日志和系统信息\n        if (isLogLine) continue;\n        if (line.match(/^\\[.*\\]$/)) continue; // [xxx]格式，包括[UCPT]\n        if (line.match(/^\\d+\\.\\d+.*$/)) continue; // 数字开头的时间戳等\n        if (line.match(/^\\s*\\d+[KM]\\s+[\\.]+\\s+\\d+%\\s+\\d+[KM]\\s+\\d+[ms]/)) continue; // wget进度信息\n        // 更严格地过滤[UCPT]相关日志\n        if (line.match(/\\[UCPT\\]/i) || line.match(/UCPT.*log/i) || line.match(/log level/i)) continue;\n        \n        // 检查是否包含有意义的内容\n        const hasChinese = /[\\u4e00-\\u9fa5]/.test(line);\n        const hasEnglish = /[a-zA-Z]{4,}/.test(line);\n        \n        // 检查是否是启动信息或echo输出（不应该被识别为结果）\n        let isStartInfo = false;\n        for (const pattern of startInfoPatterns) {\n            if (pattern.test(line)) {\n                isStartInfo = true;\n                break;\n            }\n        }\n        \n        // 检查是否是echo输出格式（如图片文件提示或提示词提示）\n        // 严格过滤：任何包含冒号空格后跟路径的行都可能是echo输出\n        if (!isStartInfo && (\n            line.match(/^(图片文件|提示词|模型文件|设备信息|平台|工作目录|检测到平台|检查模型文件|启动VLM推理):/i) || \n            line.match(/.*:\\s*\\/.*\\.(jpg|jpeg|png|bin|hbm|gguf)/i) ||\n            line.match(/^echo\\s+[\"'].*[\"']/i) || // echo命令格式\n            (line.includes(':') && line.match(/\\/.*\\.(jpg|jpeg|png|bin|hbm|gguf)/i)) // 包含路径的文件名\n        )) {\n            isStartInfo = true;\n        }\n        \n        // 检查是否是BPU错误（不应该被识别为结果）\n        let isBpuError = false;\n        for (const keyword of bpuErrorKeywords) {\n            if (line.includes(keyword)) {\n                isBpuError = true;\n                break;\n            }\n        }\n        \n        // 如果包含中文或较长的英文，且不是启动信息、echo输出或BPU错误，可能是结果\n        if (!isStartInfo && !isBpuError && (hasChinese || (hasEnglish && line.length > 15))) {\n            result = line;\n            break;\n        }\n    }\n}\n\n// 策略2：如果没找到，查找可能包含推理结果的多行文本块\nif (!result || result.length < 10) {\n    let candidateLines = [];\n    // 检查最后30行\n    for (let i = lines.length - 1; i >= Math.max(0, lines.length - 30); i--) {\n        const line = lines[i].trim();\n        if (line.length < 10) continue;\n        \n        let isLogLine = false;\n        for (const keyword of logKeywords) {\n            if (line.includes(keyword)) {\n                isLogLine = true;\n                break;\n            }\n        }\n        \n        if (!isLogLine && !line.match(/^\\[.*\\]$/) && !line.match(/^\\d+\\.\\d+/)) {\n            candidateLines.unshift(line);\n        }\n    }\n    \n    if (candidateLines.length > 0) {\n        // 合并候选行，但限制长度\n        result = candidateLines.join(' ').substring(0, 800);\n    }\n}\n\n// 策略3：如果还是没找到，检查输出中是否包含推理结果的特征\nif (!result || result.length < 10) {\n    // 查找包含中文字符或较长英文文本的部分（降低最小长度要求）\n    const chineseMatch = output.match(/[\\u4e00-\\u9fa5]{3,}[^\\n]*/g);\n    if (chineseMatch && chineseMatch.length > 0) {\n        // 过滤掉启动信息，取最后一个非启动信息的匹配\n        const filteredMatches = chineseMatch.filter(match => {\n            const trimmed = match.trim();\n            // 检查是否是启动信息或echo输出\n            for (const pattern of startInfoPatterns) {\n                if (pattern.test(trimmed)) {\n                    return false;\n                }\n            }\n            // 检查是否包含启动信息关键词\n            if (trimmed.includes('可能需要') && trimmed.includes('请耐心等待')) {\n                return false;\n            }\n            // 检查是否是echo输出格式\n            if (trimmed.match(/^(图片文件|提示词|模型文件|设备信息|平台|工作目录|检测到平台):/i) || \n                trimmed.match(/.*:\\s*\\/.*\\.(jpg|jpeg|png|bin|hbm|gguf)/i)) {\n                return false;\n            }\n            return true;\n        });\n        \n        if (filteredMatches.length > 0) {\n            // 取最后一个匹配（通常是最新的结果）\n            result = filteredMatches[filteredMatches.length - 1].trim();\n            // 清理：去除可能的日志前缀\n            result = result.replace(/^.*?([\\u4e00-\\u9fa5])/, '$1');\n        }\n    }\n    \n    // 如果中文匹配失败，尝试匹配英文文本\n    if (!result || result.length < 10) {\n        const englishMatch = output.match(/[a-zA-Z]{10,}[^\\n]*/g);\n        if (englishMatch && englishMatch.length > 0) {\n            // 过滤掉明显的日志行\n            const filtered = englishMatch.filter(line => {\n                const trimmed = line.trim();\n                return !trimmed.match(/^\\[.*\\]$/) && \n                       !trimmed.includes('INFO') && \n                       !trimmed.includes('WARN') && \n                       !trimmed.includes('ERROR') &&\n                       !trimmed.includes('DEBUG') &&\n                       !trimmed.includes('UCPT') &&\n                       !trimmed.includes('log level') &&\n                       !trimmed.includes('BPU_PLAT') &&\n                       !trimmed.includes('BPU Platform Version') &&\n                       trimmed.length > 15;\n            });\n            \n            if (filtered.length > 0) {\n                // 取最后一个匹配\n                result = filtered[filtered.length - 1].trim();\n            }\n        }\n    }\n}\n\n// 策略4：如果输出看起来还在进行中（包含启动信息但没有结果），继续等待\nconst hasStartInfo = output.includes('开始') || output.includes('启动') || output.includes('=== 开始');\nconst hasResult = result && result.length >= 10 && !result.match(/^\\[.*\\]$/);\n\n// 检查输出是否只包含启动信息（可能输出被截断）\nconst onlyStartInfo = hasStartInfo && output.includes('可能需要10-30秒') && !output.includes('llamacpp_node') && output.length < 1000;\nif (onlyStartInfo && !hasResult) {\n    node.status({ fill: 'yellow', shape: 'dot', text: '⏳ VLM正在初始化，预计需要10-30秒...' });\n    return null;\n}\n\n// 检查是否包含ros2命令的输出（说明命令已经开始执行）\nconst hasRos2Output = output.includes('ros2') || output.includes('hobot_llamacpp') || output.includes('llamacpp_node');\n\n// 如果命令已经开始执行但还没有结果，检查是否真的在推理中\nif (hasStartInfo && !hasResult) {\n    // 如果输出已经很长（>5000字符）但仍然没有结果，尝试更宽松的解析策略\n    if (output.length > 5000) {\n        // 尝试提取所有非日志行\n        let allNonLogLines = [];\n        for (let i = lines.length - 1; i >= Math.max(0, lines.length - 50); i--) {\n            const line = lines[i].trim();\n            if (line.length < 5) continue;\n            \n            // 跳过明显的日志\n            let isLogLine = false;\n            for (const keyword of logKeywords) {\n                if (line.includes(keyword)) {\n                    isLogLine = true;\n                    break;\n                }\n            }\n            \n            if (!isLogLine && \n                !line.match(/^\\[.*\\]$/) && \n                !line.match(/^\\d+\\.\\d+.*$/) && \n                !line.match(/^\\s*\\d+[KM]\\s+[\\.]+\\s+\\d+%\\s+\\d+[KM]\\s+\\d+[ms]/) &&\n                !line.match(/\\[UCPT\\]/i) &&\n                !line.match(/log level/i)) {\n                allNonLogLines.unshift(line);\n            }\n        }\n        \n        // 如果找到了一些非日志行，尝试合并它们作为结果\n        if (allNonLogLines.length > 0) {\n            const mergedResult = allNonLogLines.join(' ').substring(0, 1000);\n            // 检查合并后的结果是否包含有意义的内容\n            const hasChinese = /[\\u4e00-\\u9fa5]/.test(mergedResult);\n            const hasEnglish = /[a-zA-Z]{4,}/.test(mergedResult);\n            \n            if (hasChinese || (hasEnglish && mergedResult.length > 20)) {\n                result = mergedResult;\n            }\n        }\n    }\n    \n    // 如果还是没有结果，但输出已经很长，可能还在推理中\n    if (!result || result.length < 10) {\n        // 检查是否包含推理相关的关键词（说明推理正在进行）\n        const hasInferenceKeywords = output.includes('prefill') || \n                                     output.includes('eval time') || \n                                     output.includes('image encoder') ||\n                                     output.includes('token') ||\n                                     output.includes('生成') ||\n                                     output.includes('推理');\n        \n        if (hasInferenceKeywords || hasRos2Output) {\n            // 可能还在推理中，等待更多输出\n            const elapsedSeconds = Math.floor((Date.now() - (msg.startTime || Date.now())) / 1000);\n            node.status({ fill: 'blue', shape: 'dot', text: '🤖 AI正在分析图片中... (已用时: ' + elapsedSeconds + '秒)' });\n            return null;\n        } else if (output.length > 10000) {\n            // 输出很长但没有推理关键词，可能是解析问题，输出调试信息\n            const debugOutput = output.substring(Math.max(0, output.length - 1000));\n            node.warn('VLM输出很长但未找到结果，最后1000字符: ' + debugOutput);\n            node.status({ fill: 'orange', shape: 'dot', text: '输出过长，请检查调试信息 (' + output.length + ' chars)' });\n        }\n    }\n}\n\n// 清理结果：去除多余的空格和换行\nif (result) {\n    result = result.replace(/\\s+/g, ' ').trim();\n}\n\n// 检查结果是否是启动信息、echo输出或BPU错误（不应该被识别为结果）\nif (result) {\n    // 首先检查是否是BPU内存分配错误\n    for (const keyword of bpuErrorKeywords) {\n        if (result.includes(keyword)) {\n            // 这是BPU错误，不是推理结果\n            const errorMessage = result;\n            msg.isError = true;\n            msg.errorType = 'bpu_memory_error';\n            msg.errorMessage = 'BPU内存分配失败: ' + errorMessage + '\\n\\n可能原因：\\n1. BPU内存不足，请关闭其他占用BPU的应用\\n2. 模型文件过大，超出设备内存限制\\n3. 设备资源不足，请检查系统资源使用情况';\n            node.status({ fill: 'red', shape: 'dot', text: 'BPU内存分配失败' });\n            msg.result = errorMessage;\n            msg.fullOutput = output.substring(Math.max(0, output.length - 2000));\n            \n            // 清除缓冲区\n            if (global.vlmOutputBuffer && global.vlmOutputBuffer[msgId]) {\n                delete global.vlmOutputBuffer[msgId];\n            }\n            \n            return msg;\n        }\n    }\n    \n    // 检查启动信息模式\n    for (const pattern of startInfoPatterns) {\n        if (pattern.test(result)) {\n            // 这是启动信息或echo输出，不是结果，继续等待\n            node.status({ fill: 'yellow', shape: 'dot', text: '等待推理结果... (' + output.length + ' chars)' });\n            return null;\n        }\n    }\n    \n    // 检查是否包含启动信息关键词\n    if (result.includes('可能需要') && result.includes('请耐心等待')) {\n        node.status({ fill: 'yellow', shape: 'dot', text: '等待推理结果... (' + output.length + ' chars)' });\n        return null;\n    }\n    \n    // 检查是否是echo输出格式（如图片文件提示或提示词提示）\n    // 严格过滤：任何包含冒号空格后跟路径的行都可能是echo输出\n    if (result.match(/^(图片文件|提示词|模型文件|设备信息|平台|工作目录|检测到平台|检查模型文件|启动VLM推理):/i) || \n        result.match(/.*:\\s*\\/.*\\.(jpg|jpeg|png|bin|hbm|gguf)/i) ||\n        result.match(/.*image-\\d{8}-\\d{6}\\.(jpg|jpeg)/i) ||\n        result.match(/^echo\\s+[\"'].*[\"']/i) || // echo命令格式\n        (result.includes(':') && result.match(/\\/.*\\.(jpg|jpeg|png|bin|hbm|gguf)/i))) { // 包含路径的文件名\n        // 这是echo输出，不是结果，继续等待\n        node.status({ fill: 'yellow', shape: 'dot', text: '过滤echo输出，等待推理结果... (' + output.length + ' chars)' });\n        return null;\n    }\n}\n\n// 放宽结果识别条件：如果结果包含中文或较长的英文，即使短一些也接受\nconst hasChinese = result && /[\\u4e00-\\u9fa5]/.test(result);\nconst hasEnglish = result && /[a-zA-Z]{4,}/.test(result);\nconst minLength = hasChinese ? 8 : (hasEnglish ? 12 : 15);\n\n// 如果结果太短或看起来不像推理结果，继续等待\n// 特别检查是否是日志信息或启动信息\nif (!result || result.length < minLength || result.match(/^\\[.*\\]$/) || result.match(/\\[UCPT\\]/i) || result.match(/log level/i)) {\n    // 如果输出已经很长（>50000字符），可能是解析逻辑有问题或命令卡住，返回错误\n    if (output.length > 50000) {\n        // 输出太长，可能命令卡住或输出异常\n        const debugOutput = output.substring(Math.max(0, output.length - 2000));\n        node.error('VLM输出过长（' + output.length + '字符），可能命令卡住或输出异常。最后2000字符: ' + debugOutput);\n        node.status({ fill: 'red', shape: 'dot', text: '输出异常，请检查命令执行' });\n        \n        msg.isError = true;\n        msg.errorType = 'output_too_long';\n        msg.errorMessage = 'VLM输出过长（' + output.length + '字符），可能命令卡住或输出异常。请检查：\\n1. 命令是否正常执行\\n2. 模型文件是否存在\\n3. 设备资源是否充足\\n4. 查看Debug面板获取更多信息';\n        msg.fullOutput = debugOutput;\n        \n        // 清除缓冲区\n        cleanupGlobalVars(msgId);\n        \n        return msg;\n    } else if (output.length > 10000) {\n        // 输出较长，可能是解析问题，输出调试信息但继续等待\n        const debugOutput = output.substring(Math.max(0, output.length - 1000));\n        node.warn('VLM输出较长但未找到结果（' + output.length + '字符），最后1000字符: ' + debugOutput);\n        node.status({ fill: 'orange', shape: 'dot', text: '输出较长，等待结果... (' + output.length + ' chars)' });\n    } else {\n        node.status({ fill: 'yellow', shape: 'dot', text: '等待完整输出... (' + output.length + ' chars)' });\n    }\n    return null;\n}\n\n// 找到有效结果，保存并清除缓冲区\nmsg.result = result;\nmsg.fullOutput = output.substring(Math.max(0, output.length - 2000)); // 保存最后2000字符用于调试\n\n// 清除累积的输出（如果之前没有清除，这里清除）\n// 注意：缓冲区已在找到结果时立即清除，这里只处理未找到结果的情况\nif (global.vlmOutputBuffer && global.vlmOutputBuffer[msgId] && !msg.result) {\n    // 如果没有找到结果，延迟清除（可能还在处理中）\n    setTimeout(() => {\n        cleanupGlobalVars(msgId);\n    }, 5000);\n}\n\nnode.status({ fill: 'green', shape: 'dot', text: '✅ 推理完成！结果已获取' });\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 390,
        "y": 500,
        "wires": [
            [
                "02c1d51bc5ca4120"
            ]
        ]
    },
    {
        "id": "02c1d51bc5ca4120",
        "type": "debug",
        "z": "6bb10895ab509f5b",
        "name": "VLM推理结果",
        "active": true,
        "tosidebar": true,
        "console": true,
        "tostatus": true,
        "complete": "result",
        "targetType": "msg",
        "statusVal": "result",
        "statusType": "auto",
        "x": 640,
        "y": 200,
        "wires": []
    },
    {
        "id": "520adac9ce8a0c76",
        "type": "comment",
        "z": "6bb10895ab509f5b",
        "name": "🖼️ 本地图片回灌模式",
        "info": "使用本地图片进行VLM推理\n\n**使用方法：**\n1. 直接点击按钮：使用默认图片 `config/image2.jpg`\n2. 自定义路径：在inject节点中设置 `msg.payload` 为图片路径\n   - 绝对路径：`/home/sunrise/vlm_model/my_image.jpg`\n   - 相对路径：`config/image2.jpg`（相对于 ~/vlm_model）\n   - 支持 ~ 路径：`~/vlm_model/my_image.jpg`",
        "x": 150,
        "y": 340,
        "wires": []
    },
    {
        "id": "eabc3c6f32c1c854",
        "type": "comment",
        "z": "6bb10895ab509f5b",
        "name": "💬 自定义提示词",
        "info": "设置VLM推理的提示词（默认：描述一下这张图片.）",
        "x": 120,
        "y": 500,
        "wires": []
    },
    {
        "id": "b0ae6a6e28c1758c",
        "type": "inject",
        "z": "6bb10895ab509f5b",
        "name": "设置提示词（中文）",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": true,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "描述一下这张图片，你觉得这个人长得怎么样",
        "payloadType": "str",
        "x": 140,
        "y": 540,
        "wires": [
            [
                "f99af0bff17619be"
            ]
        ]
    },
    {
        "id": "292d5a6ede658145",
        "type": "inject",
        "z": "6bb10895ab509f5b",
        "name": "设置提示词（英文）",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "Describe the image.",
        "payloadType": "str",
        "x": 140,
        "y": 580,
        "wires": [
            [
                "f99af0bff17619be"
            ]
        ]
    },
    {
        "id": "f99af0bff17619be",
        "type": "function",
        "z": "6bb10895ab509f5b",
        "name": "保存提示词",
        "func": "// 保存提示词到全局变量\nconst promptToSave = (msg.payload && typeof msg.payload === 'string' && msg.payload.trim() !== '') \n    ? msg.payload.trim() \n    : (global.get('vlmPrompt') || '描述一下这张图片.');\n\nglobal.set('vlmPrompt', promptToSave);\nnode.status({ fill: 'green', shape: 'dot', text: '提示词: ' + promptToSave.substring(0, 20) + '...' });\nreturn null;",
        "outputs": 1,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 350,
        "y": 560,
        "wires": [
            []
        ]
    },
    {
        "id": "acc2ae5e05b0e287",
        "type": "comment",
        "z": "6bb10895ab509f5b",
        "name": "🎮 控制功能",
        "info": "停止VLM服务",
        "x": 110,
        "y": 220,
        "wires": []
    },
    {
        "id": "15fd0544fb7fcaf3",
        "type": "exec",
        "z": "6bb10895ab509f5b",
        "command": "sudo pkill -2 -f ros; sleep 3;",
        "addpay": false,
        "append": "",
        "useSpawn": "false",
        "timer": "10",
        "winHide": false,
        "oldrc": false,
        "name": "展示推理结果并停止服务",
        "x": 470,
        "y": 660,
        "wires": [
            [],
            [],
            []
        ]
    },
    {
        "id": "b3d3d9ec437e6bac",
        "type": "function",
        "z": "6bb10895ab509f5b",
        "name": "准备图片显示",
        "func": "// 处理拍照节点输出，准备显示图片\n// rdk-camera takephoto节点可能输出：\n// 1. Buffer格式的图片数据（直接可用）\n// 2. 字符串格式的文件路径（需要读取文件）\n\n// 如果payload是Buffer，直接返回到image viewer（输出1）\nif (Buffer.isBuffer(msg.payload)) {\n    node.status({ fill: 'green', shape: 'dot', text: '图片Buffer已就绪' });\n    return [null, msg]; // 第一个输出为null（不读取文件），第二个输出到image viewer\n}\n\n// 如果payload是字符串路径，需要读取文件（输出0）\nif (typeof msg.payload === 'string') {\n    let imagePath = msg.payload.trim();\n    \n    // 处理路径：支持~开头的路径\n    if (imagePath.startsWith('~')) {\n        imagePath = imagePath.replace(/^~/, '/home/sunrise');\n    }\n    \n    // 如果不是绝对路径，添加默认目录\n    if (!imagePath.startsWith('/')) {\n        imagePath = '/home/sunrise/vlm_model/' + imagePath;\n    }\n    \n    // 设置filename属性，供file in节点使用\n    msg.filename = imagePath;\n    node.status({ fill: 'blue', shape: 'dot', text: '准备读取图片: ' + path.basename(imagePath) });\n    return [msg, null]; // 第一个输出到file_read_photo，第二个输出为null\n}\n\n// 如果都不是，返回null（不显示）\nnode.status({ fill: 'yellow', shape: 'dot', text: '无法识别图片格式' });\nreturn [null, null];",
        "outputs": 2,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [
            {
                "var": "path",
                "module": "path"
            }
        ],
        "x": 380,
        "y": 340,
        "wires": [
            [
                "6e7728212d711b64"
            ],
            [
                "9a550bfa16e2077d"
            ]
        ]
    },
    {
        "id": "6e7728212d711b64",
        "type": "file in",
        "z": "6bb10895ab509f5b",
        "name": "读取图片文件",
        "filename": "",
        "format": "buffer",
        "chunk": false,
        "sendError": false,
        "encoding": "none",
        "allProps": false,
        "x": 380,
        "y": 420,
        "wires": [
            [
                "9a550bfa16e2077d"
            ]
        ]
    },
    {
        "id": "9a550bfa16e2077d",
        "type": "image viewer",
        "z": "6bb10895ab509f5b",
        "name": "显示拍摄的图片",
        "width": "640",
        "data": "payload",
        "dataType": "msg",
        "active": true,
        "x": 600,
        "y": 360,
        "wires": [
            []
        ]
    },
    {
        "id": "77fb4808261407b3",
        "type": "inject",
        "z": "6bb10895ab509f5b",
        "name": "结果展示推理",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": "",
        "topic": "",
        "payload": "stop",
        "payloadType": "str",
        "x": 110,
        "y": 660,
        "wires": [
            [
                "15fd0544fb7fcaf3"
            ]
        ]
    },
    {
        "id": "2adcdf8dc8b25411",
        "type": "global-config",
        "env": [],
        "modules": {
            "node-red-node-rdk-camera": "0.0.17",
            "node-red-contrib-image-tools": "2.1.1"
        }
    }
]