node-red-contrib-copilot 0.0.1 → 0.0.2
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.
|
@@ -45,6 +45,8 @@ module.exports = function (RED) {
|
|
|
45
45
|
// this.credentials.githubToken populated by Node-RED when authMethod === 'token'
|
|
46
46
|
this._client = null;
|
|
47
47
|
this._startPromise = null;
|
|
48
|
+
this._modelsCache = null;
|
|
49
|
+
this._modelsCacheAt = 0;
|
|
48
50
|
|
|
49
51
|
this.on('close', async (done) => {
|
|
50
52
|
if (this._client) {
|
|
@@ -55,6 +57,8 @@ module.exports = function (RED) {
|
|
|
55
57
|
}
|
|
56
58
|
this._client = null;
|
|
57
59
|
this._startPromise = null;
|
|
60
|
+
this._modelsCache = null;
|
|
61
|
+
this._modelsCacheAt = 0;
|
|
58
62
|
}
|
|
59
63
|
done();
|
|
60
64
|
});
|
|
@@ -107,20 +111,30 @@ module.exports = function (RED) {
|
|
|
107
111
|
},
|
|
108
112
|
});
|
|
109
113
|
|
|
114
|
+
const MODELS_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
115
|
+
|
|
110
116
|
// Admin endpoint: GET /copilot/models?configId=<id>
|
|
111
117
|
// Used by the prompt node editor to populate the model dropdown dynamically.
|
|
118
|
+
// Results are cached on the config node for MODELS_CACHE_TTL_MS to avoid
|
|
119
|
+
// hitting the SDK on every editor open.
|
|
112
120
|
RED.httpAdmin.get('/copilot/models', RED.auth.needsPermission('copilot-config.read'), async (req, res) => {
|
|
113
121
|
const configNode = RED.nodes.getNode(req.query.configId);
|
|
114
122
|
if (!configNode) {
|
|
115
123
|
return res.status(404).json({ error: 'Config node not found' });
|
|
116
124
|
}
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
if (configNode._modelsCache && (now - configNode._modelsCacheAt) < MODELS_CACHE_TTL_MS) {
|
|
127
|
+
return res.json(configNode._modelsCache);
|
|
128
|
+
}
|
|
117
129
|
try {
|
|
118
130
|
const client = await configNode.getClient();
|
|
119
131
|
const models = await client.listModels();
|
|
120
|
-
|
|
132
|
+
configNode._modelsCache = models.map(m => ({
|
|
121
133
|
id: m.id,
|
|
122
134
|
multiplier: m.billing ? m.billing.multiplier : null,
|
|
123
|
-
}))
|
|
135
|
+
}));
|
|
136
|
+
configNode._modelsCacheAt = now;
|
|
137
|
+
res.json(configNode._modelsCache);
|
|
124
138
|
} catch (err) {
|
|
125
139
|
res.status(500).json({ error: err.message });
|
|
126
140
|
}
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
model: { value: '' },
|
|
9
9
|
reasoningEffort: { value: '' },
|
|
10
10
|
timeout: { value: 60000 },
|
|
11
|
+
conversationId: { value: '' },
|
|
12
|
+
sessionTimeout: { value: 30 },
|
|
11
13
|
},
|
|
12
14
|
inputs: 1,
|
|
13
15
|
outputs: 2,
|
|
@@ -81,10 +83,20 @@
|
|
|
81
83
|
<label for="node-input-timeout"><i class="fa fa-clock-o"></i> Timeout (ms)</label>
|
|
82
84
|
<input type="number" id="node-input-timeout" placeholder="60000" min="1000">
|
|
83
85
|
</div>
|
|
86
|
+
<div class="form-row">
|
|
87
|
+
<label for="node-input-conversationId"><i class="fa fa-comments"></i> Conv. ID</label>
|
|
88
|
+
<input type="text" id="node-input-conversationId" placeholder="(leave blank to use node ID)">
|
|
89
|
+
</div>
|
|
90
|
+
<div class="form-row">
|
|
91
|
+
<label for="node-input-sessionTimeout"><i class="fa fa-hourglass-half"></i> Session idle (min)</label>
|
|
92
|
+
<input type="number" id="node-input-sessionTimeout" placeholder="30" min="0">
|
|
93
|
+
</div>
|
|
84
94
|
</script>
|
|
85
95
|
|
|
86
96
|
<script type="text/html" data-help-name="copilot-prompt">
|
|
87
|
-
<p>Send a prompt (with optional attachments) to GitHub Copilot and receive a response
|
|
97
|
+
<p>Send a prompt (with optional attachments) to GitHub Copilot and receive a response.
|
|
98
|
+
Conversations are stateful by default — context is preserved across messages in the same
|
|
99
|
+
conversation thread.</p>
|
|
88
100
|
|
|
89
101
|
<h3>Inputs</h3>
|
|
90
102
|
<dl class="message-properties">
|
|
@@ -102,6 +114,20 @@
|
|
|
102
114
|
</dd>
|
|
103
115
|
<dt class="optional">model <span class="property-type">string</span></dt>
|
|
104
116
|
<dd>Override the model for this message (e.g. <code>"gpt-5"</code>).</dd>
|
|
117
|
+
<dt class="optional">conversationId <span class="property-type">string</span></dt>
|
|
118
|
+
<dd>
|
|
119
|
+
Identifies the conversation thread to use. If omitted, defaults to the node's
|
|
120
|
+
configured <em>Conv. ID</em>, or the node's own ID if that is also blank.
|
|
121
|
+
Set a per-user value (e.g. a user ID or session token) to maintain separate
|
|
122
|
+
conversation threads through a single node.
|
|
123
|
+
</dd>
|
|
124
|
+
<dt class="optional">reset <span class="property-type">boolean</span></dt>
|
|
125
|
+
<dd>
|
|
126
|
+
When <code>true</code>, destroys the active session for the resolved
|
|
127
|
+
<code>conversationId</code> and swallows the message — nothing is sent to
|
|
128
|
+
Copilot and no output is produced. Use this to start a conversation over
|
|
129
|
+
from scratch. The next message will open a fresh session.
|
|
130
|
+
</dd>
|
|
105
131
|
</dl>
|
|
106
132
|
|
|
107
133
|
<h3>Outputs</h3>
|
|
@@ -110,8 +136,8 @@
|
|
|
110
136
|
<dl class="message-properties">
|
|
111
137
|
<dt>payload <span class="property-type">string</span></dt>
|
|
112
138
|
<dd>The assistant's response text.</dd>
|
|
113
|
-
<dt>
|
|
114
|
-
<dd>The
|
|
139
|
+
<dt>conversationId <span class="property-type">string</span></dt>
|
|
140
|
+
<dd>The conversation thread key used for this message.</dd>
|
|
115
141
|
<dt>events <span class="property-type">array</span></dt>
|
|
116
142
|
<dd>All session events emitted during the request.</dd>
|
|
117
143
|
</dl>
|
|
@@ -125,4 +151,17 @@
|
|
|
125
151
|
</dl>
|
|
126
152
|
</li>
|
|
127
153
|
</ol>
|
|
154
|
+
|
|
155
|
+
<h3>Session lifecycle</h3>
|
|
156
|
+
<p>
|
|
157
|
+
Sessions are created on first use and kept alive between messages so that
|
|
158
|
+
conversation context is preserved. A session is closed when:
|
|
159
|
+
</p>
|
|
160
|
+
<ul>
|
|
161
|
+
<li>A message with <code>msg.reset = true</code> is received.</li>
|
|
162
|
+
<li>The session has been idle longer than the configured <em>Session idle</em>
|
|
163
|
+
timeout (default 30 min; set to <code>0</code> to disable).</li>
|
|
164
|
+
<li>An error occurs — the session is discarded so the next message starts fresh.</li>
|
|
165
|
+
<li>The node is redeployed or Node-RED shuts down.</li>
|
|
166
|
+
</ul>
|
|
128
167
|
</script>
|
|
@@ -4,8 +4,30 @@ const fs = require('fs');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
|
|
7
|
-
const DEFAULT_MODEL = '
|
|
7
|
+
const DEFAULT_MODEL = 'gpt-4.1';
|
|
8
8
|
const DEFAULT_TIMEOUT = 60000;
|
|
9
|
+
const DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sniffs the file extension from a base64 string or Buffer by checking magic bytes.
|
|
13
|
+
* Returns e.g. '.jpg', '.png', '.gif', '.webp', '.pdf', or '' if unknown.
|
|
14
|
+
*/
|
|
15
|
+
function guessExtension(attachment) {
|
|
16
|
+
let buf;
|
|
17
|
+
if (attachment.type === 'base64' && attachment.data) {
|
|
18
|
+
buf = Buffer.from(attachment.data.slice(0, 16), 'base64');
|
|
19
|
+
} else if (attachment.type === 'buffer' && Buffer.isBuffer(attachment.data)) {
|
|
20
|
+
buf = attachment.data.slice(0, 16);
|
|
21
|
+
} else {
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
if (buf[0] === 0xFF && buf[1] === 0xD8) return '.jpg';
|
|
25
|
+
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return '.png';
|
|
26
|
+
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return '.gif';
|
|
27
|
+
if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46) return '.webp';
|
|
28
|
+
if (buf[0] === 0x25 && buf[1] === 0x50 && buf[2] === 0x44 && buf[3] === 0x46) return '.pdf';
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
9
31
|
|
|
10
32
|
/**
|
|
11
33
|
* Normalises an attachment descriptor into an SDK-compatible `{ type, path }` object.
|
|
@@ -26,7 +48,9 @@ function normaliseAttachment(attachment) {
|
|
|
26
48
|
}
|
|
27
49
|
|
|
28
50
|
const tmpDir = os.tmpdir();
|
|
29
|
-
const
|
|
51
|
+
const rawName = attachment.name || 'attachment';
|
|
52
|
+
// If the name has no extension, sniff the format from magic bytes / base64 header
|
|
53
|
+
const name = path.extname(rawName) ? rawName : rawName + guessExtension(attachment);
|
|
30
54
|
const tempFile = path.join(tmpDir, `nr-copilot-${Date.now()}-${Math.random().toString(36).slice(2)}-${name}`);
|
|
31
55
|
|
|
32
56
|
if (attachment.type === 'base64') {
|
|
@@ -56,14 +80,53 @@ module.exports = function (RED) {
|
|
|
56
80
|
this.model = config.model || DEFAULT_MODEL;
|
|
57
81
|
this.reasoningEffort = config.reasoningEffort || undefined;
|
|
58
82
|
this.timeout = parseInt(config.timeout, 10) || DEFAULT_TIMEOUT;
|
|
83
|
+
this.conversationId = config.conversationId || ''; // '' means use node.id
|
|
84
|
+
const sessionTimeoutMin = parseFloat(config.sessionTimeout);
|
|
85
|
+
this.sessionTimeoutMs = isFinite(sessionTimeoutMin) && sessionTimeoutMin > 0
|
|
86
|
+
? sessionTimeoutMin * 60 * 1000
|
|
87
|
+
: (sessionTimeoutMin === 0 ? 0 : DEFAULT_SESSION_TIMEOUT_MS);
|
|
88
|
+
|
|
89
|
+
// Map of conversationId → { session, timer|null }
|
|
90
|
+
this._sessions = new Map();
|
|
59
91
|
|
|
60
92
|
const node = this;
|
|
61
93
|
|
|
94
|
+
// Destroy a single session entry and clear its idle timer.
|
|
95
|
+
async function destroySession(key) {
|
|
96
|
+
const entry = node._sessions.get(key);
|
|
97
|
+
if (!entry) return;
|
|
98
|
+
node._sessions.delete(key);
|
|
99
|
+
clearTimeout(entry.timer);
|
|
100
|
+
try { await entry.session.destroy(); } catch (_) { /* ignore */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Arm (or re-arm) the idle timer for a session.
|
|
104
|
+
function armTimer(key) {
|
|
105
|
+
if (node.sessionTimeoutMs === 0) return null;
|
|
106
|
+
return setTimeout(() => destroySession(key), node.sessionTimeoutMs);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.on('close', async (done) => {
|
|
110
|
+
const keys = [...node._sessions.keys()];
|
|
111
|
+
await Promise.all(keys.map(destroySession));
|
|
112
|
+
done();
|
|
113
|
+
});
|
|
114
|
+
|
|
62
115
|
this.on('input', async function (msg, send, done) {
|
|
63
116
|
// Support both old (1-arg send) and new (2-arg) Node-RED APIs
|
|
64
117
|
send = send || function () { node.send.apply(node, arguments); };
|
|
65
118
|
done = done || function (err) { if (err) { node.error(err, msg); } };
|
|
66
119
|
|
|
120
|
+
// Resolve the conversation key: msg > node config > node.id
|
|
121
|
+
const convKey = msg.conversationId || node.conversationId || node.id;
|
|
122
|
+
|
|
123
|
+
// msg.reset = true: destroy the session and swallow the message
|
|
124
|
+
if (msg.reset) {
|
|
125
|
+
await destroySession(convKey);
|
|
126
|
+
node.status({ fill: 'grey', shape: 'ring', text: 'reset' });
|
|
127
|
+
return done();
|
|
128
|
+
}
|
|
129
|
+
|
|
67
130
|
node.status({ fill: 'blue', shape: 'dot', text: 'sending…' });
|
|
68
131
|
|
|
69
132
|
// --- Resolve config node ---
|
|
@@ -83,14 +146,16 @@ module.exports = function (RED) {
|
|
|
83
146
|
prompt = msg.payload;
|
|
84
147
|
} else if (msg.payload && typeof msg.payload === 'object') {
|
|
85
148
|
prompt = msg.payload.prompt || '';
|
|
86
|
-
|
|
149
|
+
const pa = msg.payload.attachments;
|
|
150
|
+
rawAttachments = Array.isArray(pa) ? pa : (pa ? [pa] : []);
|
|
87
151
|
} else {
|
|
88
152
|
prompt = String(msg.payload || '');
|
|
89
153
|
}
|
|
90
154
|
|
|
91
155
|
// msg.attachments can supplement / override
|
|
92
|
-
|
|
93
|
-
|
|
156
|
+
const ma = msg.attachments;
|
|
157
|
+
if (ma) {
|
|
158
|
+
rawAttachments = rawAttachments.concat(Array.isArray(ma) ? ma : [ma]);
|
|
94
159
|
}
|
|
95
160
|
|
|
96
161
|
// Model can be overridden per-message
|
|
@@ -112,19 +177,26 @@ module.exports = function (RED) {
|
|
|
112
177
|
return done();
|
|
113
178
|
}
|
|
114
179
|
|
|
115
|
-
// ---
|
|
116
|
-
const { approveAll } = await import('@github/copilot-sdk');
|
|
117
|
-
const sessionConfig = { model, onPermissionRequest: approveAll };
|
|
118
|
-
if (node.reasoningEffort) {
|
|
119
|
-
sessionConfig.reasoningEffort = node.reasoningEffort;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// --- Send to Copilot ---
|
|
180
|
+
// --- Get or create a session for this conversation ---
|
|
123
181
|
let client;
|
|
124
182
|
let session;
|
|
125
183
|
try {
|
|
126
184
|
client = await configNode.getClient();
|
|
127
|
-
|
|
185
|
+
|
|
186
|
+
let entry = node._sessions.get(convKey);
|
|
187
|
+
if (entry) {
|
|
188
|
+
// Re-arm the idle timer and reuse the live session
|
|
189
|
+
clearTimeout(entry.timer);
|
|
190
|
+
entry.timer = armTimer(convKey);
|
|
191
|
+
session = entry.session;
|
|
192
|
+
} else {
|
|
193
|
+
const { approveAll } = await import('@github/copilot-sdk');
|
|
194
|
+
const sessionConfig = { model, onPermissionRequest: approveAll };
|
|
195
|
+
if (node.reasoningEffort) sessionConfig.reasoningEffort = node.reasoningEffort;
|
|
196
|
+
session = await client.createSession(sessionConfig);
|
|
197
|
+
const timer = armTimer(convKey);
|
|
198
|
+
node._sessions.set(convKey, { session, timer });
|
|
199
|
+
}
|
|
128
200
|
|
|
129
201
|
const messageOptions = { prompt };
|
|
130
202
|
if (sdkAttachments.length > 0) {
|
|
@@ -137,21 +209,19 @@ module.exports = function (RED) {
|
|
|
137
209
|
const response = await session.sendAndWait(messageOptions, node.timeout);
|
|
138
210
|
|
|
139
211
|
cleanupTempFiles(tempFiles);
|
|
140
|
-
await session.destroy();
|
|
141
212
|
|
|
142
213
|
const responseText = response ? response.data.content : '';
|
|
143
214
|
node.status({ fill: 'green', shape: 'dot', text: 'done' });
|
|
144
215
|
|
|
145
216
|
msg.payload = responseText;
|
|
146
|
-
msg.
|
|
217
|
+
msg.conversationId = convKey;
|
|
147
218
|
msg.events = events;
|
|
148
219
|
send([msg, null]);
|
|
149
220
|
done();
|
|
150
221
|
} catch (err) {
|
|
151
222
|
cleanupTempFiles(tempFiles);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
223
|
+
// On error, discard the session so the next message starts fresh
|
|
224
|
+
await destroySession(convKey);
|
|
155
225
|
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
156
226
|
send([null, { payload: err.message, error: err, _msgid: msg._msgid }]);
|
|
157
227
|
done();
|