node-red-contrib-vibe-function 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # node-red-contrib-vibe-function
2
+
3
+ AI 驱动的 Node-RED Function 节点,用自然语言描述需求,自动生成、校验、修复代码。基于 **DeepSeek API**(兼容 Anthropic 协议)。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ # 进入 Node-RED 目录
9
+ cd ~/.node-red
10
+
11
+ # 克隆或创建插件目录,放入 vibe-function.js 和 vibe-function.html
12
+ mkdir -p node-red-contrib-vibe-function
13
+ cp vibe-function.* node-red-contrib-vibe-function/
14
+ ln -s ../node-red-contrib-vibe-function node_modules/node-red-contrib-vibe-function
15
+
16
+ # 设置 API Key(二选一)
17
+ export DEEPSEEK_API_KEY=sk-xxxxx # 环境变量
18
+ # 或在编辑面板中创建 vibe-function-config 配置节点
19
+
20
+ # 重启 Node-RED
21
+ node-red
22
+ ```
23
+
24
+ ## 快速上手
25
+
26
+ 1. 从节点面板拖入 **Vibe Function**(橙色、function 分类)
27
+ 2. 打开编辑面板 → **Coding** Tab
28
+ 3. 在「描述」中写:"解析输入的时间戳,格式化为 YYYY-MM-DD HH:mm:ss"
29
+ 4. 点击 **✨ 生成代码**
30
+ 5. 检查 On Message / On Start / On Stop 中的代码
31
+ 6. 部署
32
+
33
+ ## 界面
34
+
35
+ | Tab | 说明 |
36
+ |-----|------|
37
+ | **Coding** | 核心:API 配置、自然语言描述、一键生成代码 |
38
+ | **Schema** | 定义输入/输出 msg 结构,提供属性下拉快速添加 |
39
+ | **Setup** | Outputs 数量、Timeout、外部模块导入 (lodash, moment 等) |
40
+ | **On Start** | 初始化代码,节点启动时执行一次 |
41
+ | **On Message** | 消息处理函数,每次收到消息时执行 |
42
+ | **On Stop** | 清理代码,节点停止时执行 |
43
+
44
+ ## 代码生成
45
+
46
+ 一次生成三段代码,对应节点的三个生命周期:
47
+
48
+ - **On Start** — 初始化变量、建立连接(可用 `node.send()`)
49
+ - **On Message** — 消息处理逻辑(`async function`,可用 `await`、`node.send()`、`return msg`)
50
+ - **On Stop** — 清理资源、关闭连接(不可用 `node.send()`)
51
+
52
+ 在 **Schema** Tab 中描述输入输出结构后,生成的代码会更精确:
53
+
54
+ ```
55
+ Input Schema:
56
+ msg.payload = number (timestamp in ms)
57
+ msg.topic = device ID
58
+
59
+ Output Schema:
60
+ msg.payload = "YYYY-MM-DD HH:mm:ss" formatted time string
61
+ ```
62
+
63
+ ## Debug 模式
64
+
65
+ 节点上有一个 **🐛/</>** 切换开关,点击即可启用/禁用(无需重新部署)。
66
+
67
+ 启用后,运行时会对每条消息执行两道检查:
68
+
69
+ ### 1. 错误自动修复
70
+
71
+ 代码抛出异常 → 错误 + 输入 + 上下文发送给 LLM → 返回修复后代码 → 自动重试
72
+
73
+ ### 2. Schema 校验
74
+
75
+ 代码正常执行 → 输出 msg 发送给 LLM 验证 → 对比 Output Schema → 不符合则自动修复
76
+
77
+ 修复后的代码同时更新运行时和编辑器(重开编辑面板可见)。
78
+
79
+ ## API 配置节点
80
+
81
+ 在节点面板「配置」分类中拖入 **vibe-function-config**:
82
+
83
+ | 字段 | 默认值 | 说明 |
84
+ |------|--------|------|
85
+ | API 格式 | `Anthropic Messages` | 可选 Anthropic 或 OpenAI 兼容格式 |
86
+ | Base URL | `https://api.deepseek.com/anthropic` | API 基础地址 |
87
+ | Model | `deepseek-v4-pro[1m]` | 模型名称 |
88
+ | API Key | *(必填)* | 凭证,加密存储 |
89
+
90
+ 可创建多个配置节点供不同节点使用。未配置时回退到 `DEEPSEEK_API_KEY` 环境变量。
91
+
92
+ ## 兼容的 API 提供商
93
+
94
+ 同时支持 **Anthropic Messages** 和 **OpenAI Chat Completions** 两种 API 格式。
95
+
96
+ ### Anthropic 格式
97
+
98
+ | 提供商 | Base URL | 示例 Model |
99
+ |--------|----------|------------|
100
+ | DeepSeek | `https://api.deepseek.com/anthropic` | `deepseek-v4-pro[1m]` |
101
+ | Anthropic 官方 | `https://api.anthropic.com` | `claude-sonnet-4-6` |
102
+
103
+ ### OpenAI 格式
104
+
105
+ | 提供商 | Base URL | 示例 Model |
106
+ |--------|----------|------------|
107
+ | DeepSeek | `https://api.deepseek.com` | `deepseek-chat` |
108
+ | OpenAI 官方 | `https://api.openai.com` | `gpt-4o` |
109
+ | Ollama (本地) | `http://localhost:11434` | `llama3` |
110
+ | 其他兼容代理 | 自定义地址 | 自定义 |
111
+
112
+ ## 支持的内置 API
113
+
114
+ 沙箱中可直接使用:
115
+
116
+ - `node.send(msg)` / `node.done()` — 消息控制
117
+ - `context` / `flow` / `global` — 上下文存储
118
+ - `env.get("VAR")` — 读取环境变量
119
+ - `util` — Node.js util 模块
120
+ - `Buffer` / `URL` / `Date` — 标准对象
121
+ - `setTimeout` / `setInterval` / `clearTimeout` / `clearInterval` — 定时器(自动清理)
122
+ - `console` — 日志输出
123
+
124
+ ## 示例
125
+
126
+ ### 时间格式化
127
+
128
+ **描述:** 解析输入的时间戳,转换为 "YYYY-MM-DD HH:mm:ss" 格式
129
+
130
+ **Input Schema:**
131
+ ```
132
+ msg.payload = number (Unix timestamp in ms)
133
+ ```
134
+
135
+ **Output Schema:**
136
+ ```
137
+ msg.payload = "YYYY-MM-DD HH:mm:ss" formatted time string
138
+ ```
139
+
140
+ **生成代码(On Message):**
141
+ ```javascript
142
+ let ts = msg.payload;
143
+ if (typeof ts === "string") ts = Number(ts);
144
+ if (ts < 1e12) ts *= 1000;
145
+ let d = new Date(ts);
146
+ let Y = d.getFullYear();
147
+ let M = String(d.getMonth() + 1).padStart(2, "0");
148
+ let D = String(d.getDate()).padStart(2, "0");
149
+ let h = String(d.getHours()).padStart(2, "0");
150
+ let m = String(d.getMinutes()).padStart(2, "0");
151
+ let s = String(d.getSeconds()).padStart(2, "0");
152
+ msg.payload = `${Y}-${M}-${D} ${h}:${m}:${s}`;
153
+ return msg;
154
+ ```
155
+
156
+ ### HTTP 响应处理
157
+
158
+ **描述:** 解析 HTTP 响应,提取状态码和 body,如果状态码不是 2xx 则设置 error
159
+
160
+ **Output Schema:**
161
+ ```
162
+ msg.payload = parsed response body (object)
163
+ msg.statusCode = HTTP status code (number)
164
+ msg.error = error message if status not 2xx (string or undefined)
165
+ ```
166
+
167
+ ## License
168
+
169
+ MIT
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "node-red-contrib-vibe-function",
3
+ "version": "0.1.0",
4
+ "description": "AI-powered Node-RED function node — generate, validate, and self-heal code with natural language",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "node-red",
8
+ "node-red-contrib",
9
+ "function",
10
+ "llm",
11
+ "ai",
12
+ "deepseek",
13
+ "code-generation",
14
+ "self-healing",
15
+ "vibe-coding"
16
+ ],
17
+ "node-red": {
18
+ "nodes": {
19
+ "vibe-function": "vibe-function.js"
20
+ }
21
+ },
22
+ "engines": {
23
+ "node": ">=16.0.0"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/iceshard999/node-red-contrib-vibe-function"
28
+ },
29
+ "author": {
30
+ "name": "Kesong Wan",
31
+ "email": "7632470@qq.com"
32
+ }
33
+ }
@@ -0,0 +1,412 @@
1
+ <script type="text/javascript">
2
+ // 配置节点
3
+ RED.nodes.registerType('vibe-function-config', {
4
+ category: 'config',
5
+ defaults: {
6
+ name: { value: '' },
7
+ baseUrl: { value: 'https://api.deepseek.com/anthropic' },
8
+ model: { value: 'deepseek-v4-pro[1m]' },
9
+ apiFormat: { value: 'anthropic' }
10
+ },
11
+ credentials: {
12
+ apiKey: { type: 'text' }
13
+ },
14
+ label: function() {
15
+ return this.name || 'DeepSeek API';
16
+ }
17
+ });
18
+
19
+ RED.nodes.registerType('vibe-function', {
20
+ category: 'function',
21
+ color: '#fdd0a2',
22
+ icon: 'status.svg',
23
+ defaults: {
24
+ name: { value: '' },
25
+ func: { value: '\nreturn msg;' },
26
+ outputs: { value: 1 },
27
+ timeout: { value: 0 },
28
+ initialize: { value: '' },
29
+ finalize: { value: '' },
30
+ libs: { value: [] },
31
+ description: { value: '' },
32
+ debug: { value: false },
33
+ configRef: { value: '', type: 'vibe-function-config', required: false },
34
+ inputSchema: { value: '' },
35
+ outputSchema: { value: '' }
36
+ },
37
+ inputs: 1,
38
+ outputs: 1,
39
+ label: function() {
40
+ return this.name || 'vibe-function';
41
+ },
42
+ button: {
43
+ toggle: 'debug',
44
+ label: function() {
45
+ return this.debug ? 'debug: on' : 'debug: off';
46
+ },
47
+ icon: function() {
48
+ return this.debug ? 'fa fa-bug' : 'fa fa-code';
49
+ },
50
+ onclick: function() {
51
+ var node = this;
52
+ // toggle 已翻转 node.debug,直接同步到运行时
53
+ $.ajax({
54
+ url: '/vibe-function/debug-toggle/' + node.id,
55
+ method: 'POST',
56
+ contentType: 'application/json',
57
+ data: JSON.stringify({ debug: node.debug }),
58
+ error: function() {
59
+ node.debug = !node.debug; // revert
60
+ RED.notify('Debug 切换失败', 'error');
61
+ RED.view.redraw();
62
+ }
63
+ });
64
+ }
65
+ },
66
+ oneditprepare: function() {
67
+ var that = this;
68
+ var tabs = RED.tabs.create({
69
+ id: 'vibe-func-tabs',
70
+ onchange: function(tab) {
71
+ $('#vibe-func-tabs-content').children().hide();
72
+ $('#' + tab.id).show();
73
+ }
74
+ });
75
+
76
+ tabs.addTab({ id: 'vibe-tab-coding', iconClass: 'fa fa-magic', label: 'Coding' });
77
+ tabs.addTab({ id: 'vibe-tab-schema', iconClass: 'fa fa-list-alt', label: 'Schema' });
78
+ tabs.addTab({ id: 'vibe-tab-setup', iconClass: 'fa fa-cog', label: 'Setup' });
79
+ tabs.addTab({ id: 'vibe-tab-init', iconClass: 'fa fa-play', label: 'On Start' });
80
+ tabs.addTab({ id: 'vibe-tab-func', iconClass: 'fa fa-code', label: 'On Message' });
81
+ tabs.addTab({ id: 'vibe-tab-finalize', iconClass: 'fa fa-stop', label: 'On Stop' });
82
+
83
+ tabs.activateTab('vibe-tab-coding');
84
+
85
+ // ========== Module management ==========
86
+ var libsList = $('#node-input-libs-container').editableList({
87
+ addItem: function(container, i, opt) {
88
+ var row = $('<div/>').css({
89
+ 'display': 'flex', 'gap': '4px', 'margin-bottom': '4px'
90
+ }).appendTo(container);
91
+ $('<input/>', {
92
+ type: 'text', placeholder: 'module name (e.g. lodash)',
93
+ class: 'node-input-libs-module'
94
+ }).css({ flex: '1' }).appendTo(row).val(opt.module || '');
95
+ $('<input/>', {
96
+ type: 'text', placeholder: 'var name (e.g. _)',
97
+ class: 'node-input-libs-var'
98
+ }).css({ flex: '1' }).appendTo(row).val(opt.var || '');
99
+ },
100
+ removable: true
101
+ });
102
+ var libs = that.libs || [];
103
+ for (var i = 0; i < libs.length; i++) {
104
+ libsList.editableList('addItem', libs[i]);
105
+ }
106
+
107
+ // ========== Spinners ==========
108
+ $('#node-input-outputs').spinner({ min: 1, max: 500 });
109
+ $('#node-input-timeout').spinner({ min: 0, max: 4294967 });
110
+
111
+ // ========== 从运行时加载最新代码(Debug 修复后的) ==========
112
+ $.getJSON('/vibe-function/code/' + that.id, function(data) {
113
+ if (data.func && data.func !== $('#node-input-func').val()) {
114
+ $('#node-input-func').val(data.func);
115
+ }
116
+ if (data.initialize && data.initialize !== $('#node-input-initialize').val()) {
117
+ $('#node-input-initialize').val(data.initialize);
118
+ }
119
+ if (data.finalize && data.finalize !== $('#node-input-finalize').val()) {
120
+ $('#node-input-finalize').val(data.finalize);
121
+ }
122
+ });
123
+
124
+ // ========== Schema 属性快速添加 ==========
125
+ function setupSchemaAdd(selectId, inputId, btnId, textareaId) {
126
+ $('#' + btnId).on('click', function() {
127
+ var prop = $('#' + inputId).val().trim();
128
+ if (!prop) {
129
+ prop = $('#' + selectId).val();
130
+ }
131
+ if (!prop) return;
132
+ var ta = $('#' + textareaId);
133
+ var cur = ta.val().trim();
134
+ var line = prop + ' = ';
135
+ ta.val(cur ? cur + '\n' + line : line);
136
+ $('#' + inputId).val('');
137
+ $('#' + selectId).prop('selectedIndex', 0);
138
+ });
139
+ }
140
+ setupSchemaAdd('vibe-input-prop-select', 'vibe-input-custom-prop', 'vibe-input-add-btn', 'node-input-inputSchema');
141
+ setupSchemaAdd('vibe-output-prop-select', 'vibe-output-custom-prop', 'vibe-output-add-btn', 'node-input-outputSchema');
142
+
143
+ // ========== LLM Code Generation ==========
144
+ $('#vibe-generate-btn').on('click', function() {
145
+ var desc = $('#node-input-description').val();
146
+ if (!desc) {
147
+ $('#vibe-status').text('请先填写描述');
148
+ return;
149
+ }
150
+ var configNodeId = $('#node-input-configRef').val();
151
+ var inputSchema = $('#node-input-inputSchema').val();
152
+ var outputSchema = $('#node-input-outputSchema').val();
153
+
154
+ $('#vibe-status').text('生成中...');
155
+ $('#vibe-generate-btn').prop('disabled', true);
156
+
157
+ $.ajax({
158
+ url: '/vibe-function/generate',
159
+ method: 'POST',
160
+ contentType: 'application/json',
161
+ data: JSON.stringify({
162
+ description: desc,
163
+ configNodeId: configNodeId,
164
+ inputSchema: inputSchema,
165
+ outputSchema: outputSchema
166
+ }),
167
+ success: function(res) {
168
+ if (res.name) $('#node-input-name').val(res.name);
169
+ if (res.initCode) $('#node-input-initialize').val(res.initCode);
170
+ if (res.funcCode) $('#node-input-func').val(res.funcCode);
171
+ if (res.finalizeCode) $('#node-input-finalize').val(res.finalizeCode);
172
+ var parts = [];
173
+ if (res.initCode) parts.push('On Start');
174
+ if (res.funcCode) parts.push('On Message');
175
+ if (res.finalizeCode) parts.push('On Stop');
176
+ $('#vibe-status').text('已生成 → ' + parts.join(' + '));
177
+ },
178
+ error: function(err) {
179
+ $('#vibe-status').text('生成失败: ' + err.responseText);
180
+ },
181
+ complete: function() {
182
+ $('#vibe-generate-btn').prop('disabled', false);
183
+ }
184
+ });
185
+ });
186
+
187
+ // ========== Debug 自动修复代码推送 ==========
188
+ RED.comms.subscribe('vibe-function:code-fixed', function(topic, data) {
189
+ if (data && data.nodeId === that.id) {
190
+ $('#node-input-func').val(data.func);
191
+ RED.notify('🛠️ Debug 模式已自动修复代码并更新编辑器', {
192
+ type: 'success',
193
+ timeout: 5000
194
+ });
195
+ }
196
+ });
197
+ },
198
+ oneditsave: function() {
199
+ var libs = [];
200
+ $('#node-input-libs-container').editableList('items').each(function() {
201
+ var item = $(this);
202
+ var mod = item.find('.node-input-libs-module').val();
203
+ var vname = item.find('.node-input-libs-var').val();
204
+ if (mod && vname) libs.push({ module: mod, var: vname });
205
+ });
206
+ this.libs = libs;
207
+ }
208
+ });
209
+ </script>
210
+
211
+ <script type="text/html" data-template-name="vibe-function-config">
212
+ <div class="form-row">
213
+ <label for="node-config-input-name"><i class="fa fa-tag"></i> 名称</label>
214
+ <input type="text" id="node-config-input-name" placeholder="DeepSeek API">
215
+ </div>
216
+ <div class="form-row">
217
+ <label for="node-config-input-apiFormat"><i class="fa fa-plug"></i> API 格式</label>
218
+ <select id="node-config-input-apiFormat" style="width: calc(100% - 105px);">
219
+ <option value="anthropic">Anthropic Messages</option>
220
+ <option value="openai">OpenAI Chat Completions</option>
221
+ </select>
222
+ </div>
223
+ <div class="form-row">
224
+ <label for="node-config-input-baseUrl"><i class="fa fa-globe"></i> Base URL</label>
225
+ <input type="text" id="node-config-input-baseUrl" placeholder="https://api.deepseek.com/anthropic">
226
+ </div>
227
+ <div class="form-row">
228
+ <label for="node-config-input-model"><i class="fa fa-cube"></i> Model</label>
229
+ <input type="text" id="node-config-input-model" placeholder="deepseek-v4-pro[1m]">
230
+ </div>
231
+ <div class="form-row">
232
+ <label for="node-config-input-apiKey"><i class="fa fa-key"></i> API Key</label>
233
+ <input type="password" id="node-config-input-apiKey" placeholder="sk-...">
234
+ </div>
235
+ </script>
236
+
237
+ <script type="text/html" data-template-name="vibe-function">
238
+ <style>
239
+ .vibe-code-area {
240
+ width: 100%;
241
+ font-family: 'Menlo', 'Consolas', monospace;
242
+ font-size: 13px;
243
+ line-height: 1.5;
244
+ tab-size: 4;
245
+ resize: vertical;
246
+ box-sizing: border-box;
247
+ }
248
+ .vibe-full-row {
249
+ clear: both;
250
+ }
251
+ .vibe-full-row > label {
252
+ display: block;
253
+ width: 100% !important;
254
+ float: none;
255
+ margin-bottom: 4px;
256
+ }
257
+ .vibe-full-row input[type=text],
258
+ .vibe-full-row textarea,
259
+ .vibe-full-row select {
260
+ width: 100% !important;
261
+ box-sizing: border-box;
262
+ }
263
+ .vibe-modules-row { margin-bottom: 0; }
264
+ </style>
265
+
266
+ <div class="form-row">
267
+ <label for="node-input-name"><i class="fa fa-tag"></i> 名称</label>
268
+ <input type="text" id="node-input-name" style="width: calc(100% - 105px);">
269
+ </div>
270
+
271
+ <div class="form-row" style="margin-bottom: 0;">
272
+ <ul style="min-width: 500px; margin-bottom: 15px;" id="vibe-func-tabs"></ul>
273
+ </div>
274
+
275
+ <div id="vibe-func-tabs-content" style="min-height: calc(100% - 95px);">
276
+
277
+ <!-- Tab: Setup -->
278
+ <div id="vibe-tab-setup" style="display:none;">
279
+ <div class="form-row">
280
+ <label for="node-input-outputs"><i class="fa fa-random"></i> Outputs</label>
281
+ <input id="node-input-outputs" style="width: 60px;" value="1">
282
+ </div>
283
+ <div class="form-row">
284
+ <label for="node-input-timeout"><i class="fa fa-clock-o"></i> Timeout (s)</label>
285
+ <input id="node-input-timeout" style="width: 60px;" placeholder="0">
286
+ </div>
287
+ <div class="form-row vibe-modules-row">
288
+ <label><i class="fa fa-cubes"></i> 模块</label>
289
+ </div>
290
+ <div class="form-row vibe-full-row">
291
+ <ol id="node-input-libs-container" style="min-height: 80px;"></ol>
292
+ </div>
293
+ </div>
294
+
295
+ <!-- Tab: Schema -->
296
+ <div id="vibe-tab-schema" style="display:none;">
297
+ <!-- Input Schema -->
298
+ <div class="form-row vibe-full-row">
299
+ <label>Input Schema</label>
300
+ <div style="font-size:11px;color:#888;margin-bottom:4px;">
301
+ 描述输入 msg 对象的结构,会用于代码生成
302
+ </div>
303
+ <div style="display:flex;gap:4px;margin-bottom:4px;">
304
+ <select id="vibe-input-prop-select" style="flex:1;">
305
+ <option value="msg.payload">msg.payload</option>
306
+ <option value="msg.topic">msg.topic</option>
307
+ <option value="msg.qos">msg.qos</option>
308
+ <option value="msg.retain">msg.retain</option>
309
+ <option value="msg.error">msg.error</option>
310
+ <option value="msg.status">msg.status</option>
311
+ <option value="msg.headers">msg.headers</option>
312
+ <option value="msg.params">msg.params</option>
313
+ <option value="msg.req">msg.req</option>
314
+ <option value="msg.res">msg.res</option>
315
+ <option value="msg.cookies">msg.cookies</option>
316
+ <option value="msg.filename">msg.filename</option>
317
+ <option value="msg.ip">msg.ip</option>
318
+ <option value="msg._msgid">msg._msgid</option>
319
+ </select>
320
+ <input type="text" id="vibe-input-custom-prop" placeholder="自定义属性..." style="flex:1;">
321
+ <button id="vibe-input-add-btn" class="red-ui-button red-ui-button-small" style="white-space:nowrap;">+ 添加</button>
322
+ </div>
323
+ <textarea id="node-input-inputSchema" class="vibe-code-area" rows="6"
324
+ placeholder="msg.payload = number (timestamp in ms)&#10;msg.topic = device id&#10;msg.status = device status (&quot;on&quot; | &quot;off&quot;)"></textarea>
325
+ </div>
326
+ <!-- Output Schema -->
327
+ <div class="form-row vibe-full-row">
328
+ <label>Output Schema</label>
329
+ <div style="font-size:11px;color:#888;margin-bottom:4px;">
330
+ 描述输出 msg 对象的结构
331
+ </div>
332
+ <div style="display:flex;gap:4px;margin-bottom:4px;">
333
+ <select id="vibe-output-prop-select" style="flex:1;">
334
+ <option value="msg.payload">msg.payload</option>
335
+ <option value="msg.topic">msg.topic</option>
336
+ <option value="msg.qos">msg.qos</option>
337
+ <option value="msg.retain">msg.retain</option>
338
+ <option value="msg.error">msg.error</option>
339
+ <option value="msg.status">msg.status</option>
340
+ <option value="msg.headers">msg.headers</option>
341
+ <option value="msg.params">msg.params</option>
342
+ <option value="msg.res">msg.res</option>
343
+ <option value="msg.filename">msg.filename</option>
344
+ </select>
345
+ <input type="text" id="vibe-output-custom-prop" placeholder="自定义属性..." style="flex:1;">
346
+ <button id="vibe-output-add-btn" class="red-ui-button red-ui-button-small" style="white-space:nowrap;">+ 添加</button>
347
+ </div>
348
+ <textarea id="node-input-outputSchema" class="vibe-code-area" rows="6"
349
+ placeholder="msg.payload = formatted time string&#10;msg.topic = same as input"></textarea>
350
+ </div>
351
+ </div>
352
+
353
+ <!-- Tab: Coding (LLM code generation) -->
354
+ <div id="vibe-tab-coding" style="display:none;">
355
+ <div class="form-row vibe-full-row">
356
+ <label for="node-input-configRef">API 配置</label>
357
+ <input type="text" id="node-input-configRef">
358
+ </div>
359
+ <div class="form-row vibe-full-row">
360
+ <label for="node-input-description">描述</label>
361
+ <textarea id="node-input-description" class="vibe-code-area" rows="4"
362
+ placeholder="用自然语言描述你想让这个节点做什么...&#10;&#10;可以写多行,越详细生成的代码越准确。"></textarea>
363
+ </div>
364
+ <div class="form-row">
365
+ <button id="vibe-generate-btn" class="red-ui-button">
366
+ <i class="fa fa-magic"></i> 生成代码
367
+ </button>
368
+ <span id="vibe-status" style="margin-left:8px;font-size:12px;color:#999;"></span>
369
+ </div>
370
+ <div style="font-size:11px;color:#888;margin-top:8px;">
371
+ 一次生成 On Start / On Message / On Stop 三个 Tab 的代码
372
+ </div>
373
+ </div>
374
+
375
+ <!-- Tab: On Start -->
376
+ <div id="vibe-tab-init" style="display:none;">
377
+ <div class="form-row vibe-full-row">
378
+ <label>初始化代码</label>
379
+ <div style="font-size:11px;color:#888;margin-bottom:4px;">
380
+ 节点启动时执行一次。初始化变量、建立连接等。
381
+ </div>
382
+ <textarea id="node-input-initialize" class="vibe-code-area" rows="12"
383
+ placeholder="// 初始化代码,节点启动时执行&#10;context.set('count', 0);"></textarea>
384
+ </div>
385
+ </div>
386
+
387
+ <!-- Tab: On Message -->
388
+ <div id="vibe-tab-func" style="display:none;">
389
+ <div class="form-row vibe-full-row">
390
+ <label>消息处理函数</label>
391
+ <div style="font-size:11px;color:#888;margin-bottom:4px;">
392
+ 每次收到消息时执行。修改 <code>msg</code> 后 <code>return msg;</code>,或使用 <code>node.send(msg)</code>。
393
+ </div>
394
+ <textarea id="node-input-func" class="vibe-code-area" rows="16"
395
+ style="min-height: 240px;">return msg;</textarea>
396
+ </div>
397
+ </div>
398
+
399
+ <!-- Tab: On Stop -->
400
+ <div id="vibe-tab-finalize" style="display:none;">
401
+ <div class="form-row vibe-full-row">
402
+ <label>清理代码</label>
403
+ <div style="font-size:11px;color:#888;margin-bottom:4px;">
404
+ 节点停止或重新部署时执行。清理资源、关闭连接。<b>此函数中不能使用 node.send()</b>。
405
+ </div>
406
+ <textarea id="node-input-finalize" class="vibe-code-area" rows="12"
407
+ placeholder="// 清理代码,节点停止时执行&#10;// 注意:此函数中不能使用 node.send()"></textarea>
408
+ </div>
409
+ </div>
410
+
411
+ </div>
412
+ </script>
@@ -0,0 +1,660 @@
1
+ const vm = require('vm');
2
+ const util = require('util');
3
+
4
+ module.exports = function(RED) {
5
+ "use strict";
6
+
7
+ // ========== 共享的 LLM 调用工具 ==========
8
+
9
+ async function callLLM(configNodeId, promptContent) {
10
+ let baseUrl = 'https://api.deepseek.com/anthropic';
11
+ let model = 'deepseek-v4-pro[1m]';
12
+ let apiKey = process.env.DEEPSEEK_API_KEY;
13
+ let apiFormat = 'anthropic';
14
+
15
+ if (configNodeId) {
16
+ const configNode = RED.nodes.getNode(configNodeId);
17
+ if (configNode) {
18
+ if (configNode.baseUrl) baseUrl = configNode.baseUrl;
19
+ if (configNode.model) model = configNode.model;
20
+ if (configNode.apiFormat) apiFormat = configNode.apiFormat;
21
+ if (configNode.credentials && configNode.credentials.apiKey) {
22
+ apiKey = configNode.credentials.apiKey;
23
+ }
24
+ }
25
+ }
26
+
27
+ if (!apiKey) {
28
+ throw new Error('请先配置 API Key');
29
+ }
30
+
31
+ let endpoint, headers, body;
32
+ if (apiFormat === 'openai') {
33
+ endpoint = `${baseUrl}/v1/chat/completions`;
34
+ headers = {
35
+ 'Content-Type': 'application/json',
36
+ 'Authorization': `Bearer ${apiKey}`
37
+ };
38
+ body = JSON.stringify({
39
+ model,
40
+ max_tokens: 4096,
41
+ messages: [{ role: 'user', content: promptContent }]
42
+ });
43
+ } else {
44
+ endpoint = `${baseUrl}/v1/messages`;
45
+ headers = {
46
+ 'Content-Type': 'application/json',
47
+ 'x-api-key': apiKey,
48
+ 'anthropic-version': '2023-06-01'
49
+ };
50
+ body = JSON.stringify({
51
+ model,
52
+ max_tokens: 4096,
53
+ messages: [{ role: 'user', content: promptContent }]
54
+ });
55
+ }
56
+
57
+ const response = await fetch(endpoint, { method: 'POST', headers, body });
58
+ const data = await response.json();
59
+
60
+ if (data.error) {
61
+ throw new Error(data.error.message || JSON.stringify(data.error));
62
+ }
63
+
64
+ let rawText;
65
+ if (apiFormat === 'openai') {
66
+ if (data.choices && data.choices[0] && data.choices[0].message) {
67
+ rawText = data.choices[0].message.content.trim();
68
+ }
69
+ } else {
70
+ if (data.content && Array.isArray(data.content)) {
71
+ const textBlock = data.content.find(b => b.type === 'text');
72
+ if (textBlock && textBlock.text) {
73
+ rawText = textBlock.text.trim();
74
+ } else if (data.content[0] && data.content[0].text) {
75
+ rawText = data.content[0].text.trim();
76
+ }
77
+ }
78
+ if (!rawText && data.choices && data.choices[0] && data.choices[0].message) {
79
+ rawText = data.choices[0].message.content.trim();
80
+ }
81
+ }
82
+
83
+ if (!rawText) {
84
+ throw new Error('未知的响应格式: ' + JSON.stringify(data).slice(0, 200));
85
+ }
86
+
87
+ return rawText;
88
+ }
89
+
90
+ function extractJSON(rawText) {
91
+ let jsonStr = rawText;
92
+ const jsonMatch = rawText.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
93
+ if (jsonMatch) jsonStr = jsonMatch[1].trim();
94
+ return JSON.parse(jsonStr);
95
+ }
96
+
97
+ // ========== 配置节点 ==========
98
+
99
+ function LLMFunctionConfigNode(config) {
100
+ RED.nodes.createNode(this, config);
101
+ this.name = config.name;
102
+ this.baseUrl = config.baseUrl;
103
+ this.model = config.model;
104
+ this.apiFormat = config.apiFormat || 'anthropic';
105
+ }
106
+ RED.nodes.registerType('vibe-function-config', LLMFunctionConfigNode, {
107
+ defaults: {
108
+ name: { value: '' },
109
+ baseUrl: { value: 'https://api.deepseek.com/anthropic' },
110
+ model: { value: 'deepseek-v4-pro[1m]' },
111
+ apiFormat: { value: 'anthropic' }
112
+ },
113
+ credentials: {
114
+ apiKey: { type: 'text' }
115
+ }
116
+ });
117
+
118
+ // ========== Helper ==========
119
+
120
+ function sendResults(node, send, msgs, cloneFirstMessage) {
121
+ if (msgs == null) return;
122
+ if (!Array.isArray(msgs)) msgs = [msgs];
123
+ let msgCount = 0;
124
+ for (let m = 0; m < msgs.length; m++) {
125
+ if (msgs[m]) {
126
+ if (!Array.isArray(msgs[m])) msgs[m] = [msgs[m]];
127
+ for (let n = 0; n < msgs[m].length; n++) {
128
+ const msg = msgs[m][n];
129
+ if (msg !== null && msg !== undefined) {
130
+ if (typeof msg === 'object' && !Buffer.isBuffer(msg) && !Array.isArray(msg)) {
131
+ if (msgCount === 0 && cloneFirstMessage !== false) {
132
+ msgs[m][n] = RED.util.cloneMessage(msgs[m][n]);
133
+ }
134
+ msgCount++;
135
+ } else {
136
+ node.error(RED._("function.error.non-message-returned"));
137
+ }
138
+ }
139
+ }
140
+ }
141
+ }
142
+ if (msgCount > 0) send(msgs);
143
+ }
144
+
145
+ // ========== 节点运行时 ==========
146
+
147
+ function LLMFunctionNode(config) {
148
+ RED.nodes.createNode(this, config);
149
+ const node = this;
150
+ node._config = config; // 保存 flow 配置引用,用于写回修复后的代码
151
+
152
+ node.name = config.name;
153
+ node.func = config.func || 'return msg;';
154
+ node.outputs = parseInt(config.outputs) || 1;
155
+ node.timeout = (parseInt(config.timeout) || 0) * 1000;
156
+ node.ini = (config.initialize || '').trim();
157
+ node.fin = (config.finalize || '').trim();
158
+ node.libs = config.libs || [];
159
+ node.debug = config.debug || false;
160
+ node.description = config.description || '';
161
+ node.inputSchema = config.inputSchema || '';
162
+ node.outputSchema = config.outputSchema || '';
163
+ node.configRef = config.configRef;
164
+
165
+ if (RED.settings.functionExternalModules === false && node.libs.length > 0) {
166
+ node.error(RED._("function.error.externalModuleNotAllowed"));
167
+ return;
168
+ }
169
+
170
+ function buildFunctionText(userCode) {
171
+ return "var results = null;" +
172
+ "results = (async function(msg,__send__,__done__){" +
173
+ "var __msgid__ = msg._msgid;" +
174
+ "var node = {" +
175
+ "id:__node__.id," +
176
+ "name:__node__.name," +
177
+ "outputCount:__node__.outputCount," +
178
+ "log:__node__.log," +
179
+ "error:__node__.error," +
180
+ "warn:__node__.warn," +
181
+ "debug:__node__.debug," +
182
+ "trace:__node__.trace," +
183
+ "on:__node__.on," +
184
+ "status:__node__.status," +
185
+ "send:function(msgs,cloneMsg){ __node__.send(__send__,__msgid__,msgs,cloneMsg);}," +
186
+ "done:__done__" +
187
+ "};\n" +
188
+ userCode + "\n" +
189
+ "})(msg,__send__,__done__);";
190
+ }
191
+
192
+ const sandbox = {
193
+ console, util, Buffer, URL, URLSearchParams, Date,
194
+ __node__: {
195
+ id: node.id,
196
+ name: node.name,
197
+ outputCount: node.outputs,
198
+ log: function() { node.log.apply(node, arguments); },
199
+ error: function() { node.error.apply(node, arguments); },
200
+ warn: function() { node.warn.apply(node, arguments); },
201
+ debug: function() { node.debug.apply(node, arguments); },
202
+ trace: function() { node.trace.apply(node, arguments); },
203
+ send: function(send, id, msgs, cloneMsg) { sendResults(node, send, msgs, cloneMsg); },
204
+ on: function() {
205
+ if (arguments[0] === "input") throw new Error(RED._("function.error.inputListener"));
206
+ node.on.apply(node, arguments);
207
+ },
208
+ status: function() { node.status.apply(node, arguments); }
209
+ },
210
+ context: {
211
+ set: function() { node.context().set.apply(node, arguments); },
212
+ get: function() { return node.context().get.apply(node, arguments); },
213
+ keys: function() { return node.context().keys.apply(node, arguments); },
214
+ get global() { return node.context().global; },
215
+ get flow() { return node.context().flow; }
216
+ },
217
+ flow: {
218
+ set: function() { node.context().flow.set.apply(node, arguments); },
219
+ get: function() { return node.context().flow.get.apply(node, arguments); },
220
+ keys: function() { return node.context().flow.keys.apply(node, arguments); }
221
+ },
222
+ global: {
223
+ set: function() { node.context().global.set.apply(node, arguments); },
224
+ get: function() { return node.context().global.get.apply(node, arguments); },
225
+ keys: function() { return node.context().global.keys.apply(node, arguments); }
226
+ },
227
+ env: { get: function(envVar) { return RED.util.getSetting(node, envVar); } },
228
+ setTimeout: function() {
229
+ const func = arguments[0]; let timerId;
230
+ arguments[0] = function() {
231
+ sandbox.clearTimeout(timerId);
232
+ try { func.apply(node, arguments); } catch(err) { node.error(err, {}); }
233
+ };
234
+ timerId = setTimeout.apply(node, arguments);
235
+ node.outstandingTimers.push(timerId);
236
+ return timerId;
237
+ },
238
+ clearTimeout: function(id) {
239
+ clearTimeout(id);
240
+ const index = node.outstandingTimers.indexOf(id);
241
+ if (index > -1) node.outstandingTimers.splice(index, 1);
242
+ },
243
+ setInterval: function() {
244
+ const func = arguments[0]; let timerId;
245
+ arguments[0] = function() {
246
+ try { func.apply(node, arguments); } catch(err) { node.error(err, {}); }
247
+ };
248
+ timerId = setInterval.apply(node, arguments);
249
+ node.outstandingIntervals.push(timerId);
250
+ return timerId;
251
+ },
252
+ clearInterval: function(id) {
253
+ clearInterval(id);
254
+ const index = node.outstandingIntervals.indexOf(id);
255
+ if (index > -1) node.outstandingIntervals.splice(index, 1);
256
+ }
257
+ };
258
+
259
+ node.outstandingTimers = [];
260
+ node.outstandingIntervals = [];
261
+
262
+ // Debug 校验与修复
263
+ let debugValidationCount = 0;
264
+ const DEBUG_MAX_VALIDATIONS = 50; // 最多校验 50 次,避免无限循环
265
+
266
+ async function validateOutput(outputMsg, inputMsg) {
267
+ if (!node.outputSchema) return true;
268
+ if (debugValidationCount >= DEBUG_MAX_VALIDATIONS) return true;
269
+ debugValidationCount++;
270
+ try {
271
+ const prompt = `You are a validator. Check if this Node-RED function output matches the expected schema.
272
+
273
+ Input msg: ${JSON.stringify(inputMsg)}
274
+ Output msg: ${JSON.stringify(outputMsg)}
275
+ Expected output schema: ${node.outputSchema}
276
+
277
+ Reply EXACTLY "YES" if the output matches the schema, or "NO: <reason>" if it does not.`;
278
+
279
+ const response = await callLLM(node.configRef, prompt);
280
+ const ok = response.trim().toUpperCase().startsWith('YES');
281
+ if (!ok) {
282
+ node.warn('[Debug] 输出不符合 Schema: ' + response.trim().substring(0, 120));
283
+ }
284
+ return ok;
285
+ } catch (err) {
286
+ // 校验失败不影响流程
287
+ node.warn('[Debug] Schema 校验请求失败: ' + err.message);
288
+ return true;
289
+ }
290
+ }
291
+
292
+ async function autoFixCode(reason, inputMsg) {
293
+ if (!node.debug) return null;
294
+ const canCall = !!(process.env.DEEPSEEK_API_KEY || (node.configRef && RED.nodes.getNode(node.configRef)));
295
+ if (!canCall) return null;
296
+ try {
297
+ const prompt = `你是一个 Node-RED function 节点调试修复器。
298
+ 以下代码需要修复,请返回修复后的代码。
299
+
300
+ 原始描述:${node.description || '无'}
301
+ 输入 Schema:${node.inputSchema || '无'}
302
+ 输出 Schema:${node.outputSchema || '无'}
303
+
304
+ 当前代码:
305
+ \`\`\`javascript
306
+ ${node.func}
307
+ \`\`\`
308
+
309
+ 收到的输入 msg:${JSON.stringify(inputMsg, null, 2)}
310
+
311
+ 问题:${reason}
312
+
313
+ 请只返回修复后的纯代码,不要包含任何解释、markdown 或 JSON 包装。直接输出可执行的 JavaScript 函数体。`;
314
+
315
+ const fixedCode = await callLLM(node.configRef, prompt);
316
+ const cleaned = fixedCode.replace(/```(?:javascript)?\s*\n?/g, '').trim();
317
+ node.func = cleaned;
318
+
319
+ // 重新编译脚本
320
+ node.script = new vm.Script(buildFunctionText(cleaned), {
321
+ filename: 'Function:' + node.id,
322
+ displayErrors: true
323
+ });
324
+
325
+ // 写回 flow 配置 + 推送给编辑器
326
+ if (node._config) {
327
+ node._config.func = cleaned;
328
+ }
329
+ try {
330
+ RED.comms.publish('vibe-function:code-fixed', {
331
+ nodeId: node.id,
332
+ func: cleaned
333
+ }, false);
334
+ } catch (e) {
335
+ // comms 不可用也不影响
336
+ }
337
+ node.warn('[Debug] 代码已自动修复');
338
+
339
+ return cleaned;
340
+ } catch (fixErr) {
341
+ node.error('[Debug] 自动修复失败: ' + fixErr.message);
342
+ return null;
343
+ }
344
+ }
345
+
346
+ function extractFirstOutputMsg(results) {
347
+ if (!results) return null;
348
+ // results 可能是: msg, [msg], [[msg]], [[msg1, msg2]]
349
+ let arr = results;
350
+ if (!Array.isArray(arr)) return arr;
351
+ if (arr.length === 0) return null;
352
+ if (Array.isArray(arr[0])) arr = arr[0];
353
+ return arr[0] || null;
354
+ }
355
+
356
+ // 加载外部模块
357
+ const moduleLoadPromises = [];
358
+ if (node.hasOwnProperty("libs")) {
359
+ node.libs.forEach(mod => {
360
+ const vname = mod.var || mod.name;
361
+ if (vname && vname !== "") {
362
+ if (sandbox.hasOwnProperty(vname) || vname === 'node') {
363
+ node.error(RED._("function.error.moduleNameError", { name: vname }));
364
+ return;
365
+ }
366
+ sandbox[vname] = null;
367
+ const spec = mod.module || mod.spec;
368
+ if (spec && spec !== "") {
369
+ moduleLoadPromises.push(
370
+ RED.import(spec).then(lib => {
371
+ sandbox[vname] = lib.default || lib;
372
+ }).catch(err => {
373
+ node.error(RED._("function.error.moduleLoadError", { module: spec, error: err.toString() }));
374
+ throw err;
375
+ })
376
+ );
377
+ }
378
+ }
379
+ });
380
+ }
381
+
382
+ const RESOLVING = 0, RESOLVED = 1;
383
+ let state = RESOLVING;
384
+ let messages = [];
385
+ let processMessage = () => {};
386
+
387
+ node.on("input", function(msg, send, done) {
388
+ if (state === RESOLVING) {
389
+ messages.push({ msg, send, done });
390
+ } else if (state === RESOLVED) {
391
+ processMessage(msg, send, done);
392
+ }
393
+ });
394
+
395
+ Promise.all(moduleLoadPromises).then(() => {
396
+ const context = vm.createContext(sandbox);
397
+
398
+ let iniScript = null, iniOpt = null;
399
+ if (node.ini) {
400
+ const iniText = `
401
+ (async function(__send__) {
402
+ var node = {
403
+ id:__node__.id,
404
+ name:__node__.name,
405
+ outputCount:__node__.outputCount,
406
+ log:__node__.log,
407
+ error:__node__.error,
408
+ warn:__node__.warn,
409
+ debug:__node__.debug,
410
+ trace:__node__.trace,
411
+ status:__node__.status,
412
+ send: function(msgs, cloneMsg) {
413
+ __node__.send(__send__, RED.util.generateId(), msgs, cloneMsg);
414
+ }
415
+ };
416
+ ` + node.ini + `
417
+ })(__initSend__);`;
418
+ iniOpt = { filename: 'Setup:' + node.id };
419
+ if (node.timeout > 0) { iniOpt.timeout = node.timeout; iniOpt.breakOnSigint = true; }
420
+ iniScript = new vm.Script(iniText, iniOpt);
421
+ }
422
+
423
+ node.script = new vm.Script(buildFunctionText(node.func), {
424
+ filename: 'Function:' + node.id,
425
+ displayErrors: true
426
+ });
427
+
428
+ let finScript = null, finOpt = null;
429
+ if (node.fin) {
430
+ const finText = `(function() {
431
+ var node = {
432
+ id:__node__.id,
433
+ name:__node__.name,
434
+ outputCount:__node__.outputCount,
435
+ log:__node__.log,
436
+ error:__node__.error,
437
+ warn:__node__.warn,
438
+ debug:__node__.debug,
439
+ trace:__node__.trace,
440
+ status:__node__.status,
441
+ send: function() { __node__.error("Cannot send from close function"); }
442
+ };
443
+ ` + node.fin + `
444
+ })();`;
445
+ finOpt = { filename: 'Cleanup:' + node.id };
446
+ if (node.timeout > 0) { finOpt.timeout = node.timeout; finOpt.breakOnSigint = true; }
447
+ finScript = new vm.Script(finText, finOpt);
448
+ }
449
+
450
+ let promise = Promise.resolve();
451
+ if (iniScript) {
452
+ context.__initSend__ = function(msgs) { node.send(msgs); };
453
+ promise = iniScript.runInContext(context, iniOpt);
454
+ }
455
+
456
+ // Debug 启动检查
457
+ if (node.debug) {
458
+ const hasConfig = !!(node.configRef && RED.nodes.getNode(node.configRef));
459
+ const hasEnvKey = !!process.env.DEEPSEEK_API_KEY;
460
+ if (!hasConfig && !hasEnvKey) {
461
+ node.warn('[Debug] 未配置 API Key,自动修复不会生效。请在 Coding Tab 中选择 API 配置或设置 DEEPSEEK_API_KEY 环境变量');
462
+ }
463
+ }
464
+
465
+ processMessage = function(msg, send, done) {
466
+ context.msg = msg;
467
+ context.__send__ = send;
468
+ context.__done__ = done;
469
+ let opts = {};
470
+ if (node.timeout > 0) { opts.timeout = node.timeout; opts.breakOnSigint = true; }
471
+
472
+ async function runAndMaybeFix() {
473
+ // 第一阶段:执行脚本(同步错误在此捕获,如语法错误)
474
+ try {
475
+ node.script.runInContext(context, opts);
476
+ } catch (err) {
477
+ return handleError(err);
478
+ }
479
+ // 第二阶段:await 结果(用户代码逻辑错误在此 reject)
480
+ try {
481
+ const results = await context.results;
482
+ // Debug 模式:校验输出是否符合 Schema
483
+ if (node.debug && node.outputSchema) {
484
+ const outputMsg = extractFirstOutputMsg(results);
485
+ const isValid = await validateOutput(outputMsg, msg);
486
+ if (!isValid) {
487
+ return handleError(
488
+ new Error('输出不符合 Schema: ' + node.outputSchema)
489
+ );
490
+ }
491
+ }
492
+ sendResults(node, send, results, false);
493
+ done();
494
+ } catch (err) {
495
+ return handleError(err);
496
+ }
497
+ }
498
+
499
+ async function handleError(err) {
500
+ if (err && err.hasOwnProperty("stack")) msg.error = err;
501
+ if (!node.debug) {
502
+ done(err);
503
+ return;
504
+ }
505
+ const errMsg = err.stack || err.message || String(err);
506
+ node.warn('[Debug] 代码执行出错,尝试自动修复...');
507
+ const fixed = await autoFixCode(errMsg, msg);
508
+ if (fixed) {
509
+ try {
510
+ node.script.runInContext(context, opts);
511
+ const results = await context.results;
512
+ // Debug 模式:修复后也校验输出
513
+ if (node.debug && node.outputSchema) {
514
+ const outputMsg = extractFirstOutputMsg(results);
515
+ const isValid = await validateOutput(outputMsg, msg);
516
+ if (!isValid) {
517
+ node.warn('[Debug] 修复后输出仍不符合 Schema,停止重试');
518
+ }
519
+ }
520
+ sendResults(node, send, results, false);
521
+ done();
522
+ return;
523
+ } catch (retryErr) {
524
+ done(retryErr);
525
+ return;
526
+ }
527
+ }
528
+ done(err);
529
+ }
530
+
531
+ runAndMaybeFix();
532
+ };
533
+
534
+ state = RESOLVED;
535
+ messages.forEach(m => processMessage(m.msg, m.send, m.done));
536
+ messages = [];
537
+ }).catch(err => {
538
+ node.error(err);
539
+ });
540
+
541
+ node.on("close", function() {
542
+ if (node.fin) {
543
+ const ctx = vm.createContext(Object.assign({}, sandbox));
544
+ try {
545
+ const s = new vm.Script(
546
+ `(function() {
547
+ var node = {
548
+ id:__node__.id, name:__node__.name,
549
+ outputCount:__node__.outputCount,
550
+ log:__node__.log, error:__node__.error,
551
+ warn:__node__.warn, debug:__node__.debug,
552
+ trace:__node__.trace, status:__node__.status,
553
+ send: function() { __node__.error("Cannot send from close function"); }
554
+ };
555
+ ` + node.fin + `
556
+ })();`,
557
+ { filename: 'Cleanup:' + node.id }
558
+ );
559
+ s.runInContext(ctx);
560
+ } catch (err) { node.error(err); }
561
+ }
562
+ node.outstandingTimers.forEach(t => clearTimeout(t));
563
+ node.outstandingIntervals.forEach(t => clearInterval(t));
564
+ });
565
+ }
566
+
567
+ // Admin API: 即时切换 debug 模式(无需 redeploy)
568
+ RED.httpAdmin.post('/vibe-function/debug-toggle/:id', function(req, res) {
569
+ const node = RED.nodes.getNode(req.params.id);
570
+ if (node && node.type === 'vibe-function') {
571
+ node.debug = !!req.body.debug;
572
+ res.json({ ok: true, debug: node.debug });
573
+ } else {
574
+ res.status(404).send('Node not found');
575
+ }
576
+ });
577
+
578
+ // Admin API: 获取节点当前运行中的代码
579
+ RED.httpAdmin.get('/vibe-function/code/:id', function(req, res) {
580
+ const node = RED.nodes.getNode(req.params.id);
581
+ if (node && node.type === 'vibe-function') {
582
+ res.json({
583
+ func: node.func || '',
584
+ initialize: node.ini || '',
585
+ finalize: node.fin || ''
586
+ });
587
+ } else {
588
+ res.json({});
589
+ }
590
+ });
591
+
592
+ // ========== Admin API: 生成代码 ==========
593
+
594
+ RED.httpAdmin.post('/vibe-function/generate', async function(req, res) {
595
+ const { description, configNodeId, inputSchema, outputSchema } = req.body;
596
+
597
+ let schemaHint = '';
598
+ if (inputSchema) schemaHint += `\n输入 msg 结构:\n${inputSchema}`;
599
+ if (outputSchema) schemaHint += `\n输出 msg 结构:\n${outputSchema}`;
600
+
601
+ const promptContent = `你是一个 Node-RED function 节点代码生成器。
602
+ 根据以下描述,生成三部分 JavaScript 代码,分别对应节点的三个生命周期。
603
+
604
+ 1. On Start (初始化代码) — 节点启动时执行一次,用于初始化变量、建立连接。可用 context, node.send()。
605
+ 2. On Message (消息处理) — 每次收到消息时执行,async function 内部。通过 return msg 输出,可用 node.send()。
606
+ 3. On Stop (清理代码) — 节点停止时执行,用于清理资源、关闭连接。不能用 node.send()。
607
+
608
+ 请严格按以下 JSON 格式输出,不要包含任何其他文字:
609
+
610
+ {
611
+ "name": "节点名称(简短中文,如:时间格式化)",
612
+ "initCode": "// On Start 代码",
613
+ "funcCode": "// On Message 代码",
614
+ "finalizeCode": "// On Stop 代码"
615
+ }
616
+
617
+ 注意:
618
+ - 只返回 JSON,不要包含解释、注释或 markdown
619
+ - JSON 字符串中的换行用 \\n 转义,引号用 \\" 转义
620
+ - 如果某部分不需要代码,设为空字符串 ""
621
+ ${schemaHint}
622
+ 描述:${description}`;
623
+
624
+ try {
625
+ const rawText = await callLLM(configNodeId, promptContent);
626
+ const result = extractJSON(rawText);
627
+ res.json({
628
+ name: result.name || '',
629
+ initCode: result.initCode || '',
630
+ funcCode: result.funcCode || '',
631
+ finalizeCode: result.finalizeCode || ''
632
+ });
633
+ } catch(err) {
634
+ RED.log.error(`[vibe-function] 生成失败: ${err.message}`);
635
+ res.status(500).send(err.message);
636
+ }
637
+ });
638
+
639
+ RED.nodes.registerType('vibe-function', LLMFunctionNode, {
640
+ defaults: {
641
+ name: { value: '' },
642
+ func: { value: '\nreturn msg;' },
643
+ outputs: { value: 1 },
644
+ timeout: { value: 0 },
645
+ initialize: { value: '' },
646
+ finalize: { value: '' },
647
+ libs: { value: [] },
648
+ description: { value: '' },
649
+ debug: { value: false },
650
+ configRef: { value: '', type: 'vibe-function-config', required: false },
651
+ inputSchema: { value: '' },
652
+ outputSchema: { value: '' }
653
+ },
654
+ inputs: 1,
655
+ outputs: 1,
656
+ label: function() {
657
+ return this.name || 'vibe-function';
658
+ }
659
+ });
660
+ };