node-red-contrib-copilot 0.0.1
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 +223 -0
- package/index.js +6 -0
- package/nodes/copilot-config/copilot-config.html +69 -0
- package/nodes/copilot-config/copilot-config.js +131 -0
- package/nodes/copilot-prompt/copilot-prompt.html +128 -0
- package/nodes/copilot-prompt/copilot-prompt.js +163 -0
- package/nodes/copilot-prompt/icons/copilot.svg +11 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# node-red-contrib-copilot
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
Embed GitHub Copilot into your Node-RED flows. Send prompts and file attachments to any Copilot model and wire the response into the rest of your automation.
|
|
8
|
+
|
|
9
|
+
Built on the [`@github/copilot-sdk`](https://github.com/github/copilot-sdk) — the same engine that powers Copilot CLI.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
### GitHub Copilot subscription
|
|
16
|
+
|
|
17
|
+
A GitHub Copilot subscription is required. A free tier with limited usage is available — see [GitHub Copilot pricing](https://github.com/features/copilot#pricing).
|
|
18
|
+
|
|
19
|
+
### Authentication
|
|
20
|
+
|
|
21
|
+
Two methods are supported:
|
|
22
|
+
|
|
23
|
+
| Method | When to use |
|
|
24
|
+
|--------|-------------|
|
|
25
|
+
| **OAuth (default)** | You are logged in via `gh auth login` or the Copilot CLI on the host machine |
|
|
26
|
+
| **Fine-grained PAT** | Headless / containerised deployments; token must have the **Copilot Requests** permission |
|
|
27
|
+
|
|
28
|
+
> ⚠️ Classic PATs with the `copilot` scope do **not** work. You must use a fine-grained PAT with **Copilot Requests** permission.
|
|
29
|
+
|
|
30
|
+
### Node.js
|
|
31
|
+
|
|
32
|
+
**Node.js v24** is required. The `@github/copilot` CLI binary bundled with the SDK is a native binary that requires a glibc-based (non-musl) environment.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Docker
|
|
37
|
+
|
|
38
|
+
The recommended container is:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
nodered/node-red-dev:5.0.0-beta.3-debian
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
This image provides:
|
|
45
|
+
- Node-RED 5.0 (beta)
|
|
46
|
+
- **Node.js v24** — required by the bundled Copilot CLI binary
|
|
47
|
+
- **Debian (glibc)** — the CLI binary is dynamically linked against glibc; Alpine (musl) is **not** supported
|
|
48
|
+
|
|
49
|
+
### Quick start
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
docker run -d \
|
|
53
|
+
--name nodered \
|
|
54
|
+
-p 1880:1880 \
|
|
55
|
+
-v /your/data:/data \
|
|
56
|
+
nodered/node-red-dev:5.0.0-beta.3-debian
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
### Via the Node-RED Palette Manager
|
|
64
|
+
|
|
65
|
+
Search for `node-red-contrib-copilot` in the Palette Manager and click **Install**.
|
|
66
|
+
|
|
67
|
+
### Via npm (inside your Node-RED data directory)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
cd /your/node-red/data
|
|
71
|
+
npm install node-red-contrib-copilot
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### In Docker
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
docker exec nodered npm install node-red-contrib-copilot --prefix /data
|
|
78
|
+
docker restart nodered
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Nodes
|
|
84
|
+
|
|
85
|
+
### `copilot-config` (configuration node)
|
|
86
|
+
|
|
87
|
+
Holds credentials and connection settings. Referenced by one or more `copilot` nodes.
|
|
88
|
+
|
|
89
|
+
| Field | Description |
|
|
90
|
+
|-------|-------------|
|
|
91
|
+
| **Name** | Label for this configuration |
|
|
92
|
+
| **Auth method** | `oauth` — use locally stored `gh` credentials; `token` — use a fine-grained PAT |
|
|
93
|
+
| **Token** | Fine-grained PAT with **Copilot Requests** permission (only when auth method is `token`) |
|
|
94
|
+
| **CLI Path** | Override the path to the `copilot` binary (leave blank to use the bundled binary) |
|
|
95
|
+
| **CLI URL** | Connect to an external CLI server instead of spawning a local process |
|
|
96
|
+
|
|
97
|
+
### `copilot` (prompt node)
|
|
98
|
+
|
|
99
|
+
Sends a prompt to GitHub Copilot and emits the response.
|
|
100
|
+
|
|
101
|
+
#### Inputs
|
|
102
|
+
|
|
103
|
+
| Property | Type | Description |
|
|
104
|
+
|----------|------|-------------|
|
|
105
|
+
| `msg.payload` | `string` | Plain string used as the prompt |
|
|
106
|
+
| `msg.payload` | `object` | `{ prompt: string, attachments: Attachment[] }` |
|
|
107
|
+
| `msg.attachments` | `Attachment[]` | Merged with any attachments in `msg.payload` |
|
|
108
|
+
| `msg.model` | `string` | Override the model for this message only |
|
|
109
|
+
|
|
110
|
+
#### Attachment formats
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
// File path (passed directly to the SDK)
|
|
114
|
+
{ type: "file", path: "/absolute/path/to/file.png" }
|
|
115
|
+
|
|
116
|
+
// Base64-encoded data
|
|
117
|
+
{ type: "base64", data: "<base64 string>", name: "image.jpg" }
|
|
118
|
+
|
|
119
|
+
// Node.js Buffer
|
|
120
|
+
{ type: "buffer", data: Buffer.from(...), name: "file.bin" }
|
|
121
|
+
|
|
122
|
+
// Shorthand — type is inferred as "file"
|
|
123
|
+
{ path: "/absolute/path/to/file.txt" }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
#### Outputs
|
|
127
|
+
|
|
128
|
+
| Output | Property | Type | Description |
|
|
129
|
+
|--------|----------|------|-------------|
|
|
130
|
+
| **1 — Response** | `msg.payload` | `string` | The assistant's response text |
|
|
131
|
+
| | `msg.sessionId` | `string` | Copilot session ID |
|
|
132
|
+
| | `msg.events` | `array` | All events emitted during the session |
|
|
133
|
+
| **2 — Error** | `msg.payload` | `string` | Error message |
|
|
134
|
+
| | `msg.error` | `Error` | The error object |
|
|
135
|
+
|
|
136
|
+
#### Node configuration
|
|
137
|
+
|
|
138
|
+
| Field | Description |
|
|
139
|
+
|-------|-------------|
|
|
140
|
+
| **Config** | Select a `copilot-config` node |
|
|
141
|
+
| **Model** | Dynamically populated from the API — shows token cost multiplier, e.g. `claude-haiku-4.5 (0x)` |
|
|
142
|
+
| **Reasoning** | Reasoning effort hint: `low`, `medium`, `high`, `xhigh` (model-dependent) |
|
|
143
|
+
| **Timeout** | Request timeout in milliseconds (default: 60,000) |
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Architecture
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
Node-RED flow
|
|
151
|
+
↓
|
|
152
|
+
copilot node
|
|
153
|
+
↓
|
|
154
|
+
@github/copilot-sdk (Node.js)
|
|
155
|
+
↓ JSON-RPC
|
|
156
|
+
Copilot CLI (bundled, spawned as subprocess)
|
|
157
|
+
↓ HTTPS
|
|
158
|
+
GitHub Copilot API
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The SDK manages the CLI process lifecycle automatically. The CLI binary is bundled with the `@github/copilot` package (a dependency of `@github/copilot-sdk`) and is resolved automatically at startup — no manual PATH configuration needed.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Example flow
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
[
|
|
169
|
+
{ "id": "cfg1", "type": "copilot-config", "name": "My Copilot", "authMethod": "token" },
|
|
170
|
+
{ "id": "inj1", "type": "inject", "payload": "What is the capital of France?", "payloadType": "str", "wires": [["prompt1"]] },
|
|
171
|
+
{ "id": "prompt1", "type": "copilot-prompt", "copilotConfig": "cfg1", "model": "gpt-4.1", "wires": [["dbg1"], ["err1"]] },
|
|
172
|
+
{ "id": "dbg1", "type": "debug", "complete": "payload" },
|
|
173
|
+
{ "id": "err1", "type": "debug", "complete": "payload" }
|
|
174
|
+
]
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
git clone https://github.com/yourname/node-red-contrib-copilot
|
|
183
|
+
cd node-red-contrib-copilot
|
|
184
|
+
npm install
|
|
185
|
+
|
|
186
|
+
# Unit tests (mocked, no API key required)
|
|
187
|
+
npm test
|
|
188
|
+
|
|
189
|
+
# Integration tests (real API — requires a fine-grained PAT)
|
|
190
|
+
GITHUB_TOKEN=<your-fine-grained-pat> npm run test:integration
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Deploying to Docker during development
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
npm pack
|
|
197
|
+
cp node-red-contrib-copilot-*.tgz ~/your/node-red/data/
|
|
198
|
+
docker exec nodered npm install /data/node-red-contrib-copilot-*.tgz --prefix /data
|
|
199
|
+
docker restart nodered
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Dependencies
|
|
205
|
+
|
|
206
|
+
| Package | Role |
|
|
207
|
+
|---------|------|
|
|
208
|
+
| [`@github/copilot-sdk`](https://www.npmjs.com/package/@github/copilot-sdk) `0.1.30` | Copilot client — session management, model listing, prompt dispatch |
|
|
209
|
+
| [`@github/copilot`](https://www.npmjs.com/package/@github/copilot) | Copilot CLI binary (bundled, installed transitively via the SDK) |
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Billing
|
|
214
|
+
|
|
215
|
+
Each prompt counts against your Copilot premium request quota. The model dropdown shows each model's cost multiplier (e.g. `(0x)` = free/included, `(1x)` = one premium request). See [Requests in GitHub Copilot](https://docs.github.com/en/copilot/concepts/billing/copilot-requests) for details.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
ISC
|
|
222
|
+
|
|
223
|
+
Icon derived from [primer/octicons](https://github.com/primer/octicons) — MIT License © GitHub, Inc.
|
package/index.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('copilot-config', {
|
|
3
|
+
category: 'config',
|
|
4
|
+
defaults: {
|
|
5
|
+
name: { value: '' },
|
|
6
|
+
authMethod: { value: 'oauth' },
|
|
7
|
+
cliPath: { value: '' },
|
|
8
|
+
cliUrl: { value: '' },
|
|
9
|
+
},
|
|
10
|
+
credentials: {
|
|
11
|
+
githubToken: { type: 'password' },
|
|
12
|
+
},
|
|
13
|
+
label: function () {
|
|
14
|
+
return this.name || 'GitHub Copilot';
|
|
15
|
+
},
|
|
16
|
+
oneditprepare: function () {
|
|
17
|
+
function toggleTokenField() {
|
|
18
|
+
const method = $('#node-config-input-authMethod').val();
|
|
19
|
+
$('#copilot-token-row').toggle(method === 'token');
|
|
20
|
+
}
|
|
21
|
+
$('#node-config-input-authMethod').on('change', toggleTokenField);
|
|
22
|
+
toggleTokenField();
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<script type="text/html" data-template-name="copilot-config">
|
|
28
|
+
<div class="form-row">
|
|
29
|
+
<label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
30
|
+
<input type="text" id="node-config-input-name" placeholder="GitHub Copilot">
|
|
31
|
+
</div>
|
|
32
|
+
<div class="form-row">
|
|
33
|
+
<label for="node-config-input-authMethod"><i class="fa fa-lock"></i> Auth</label>
|
|
34
|
+
<select id="node-config-input-authMethod">
|
|
35
|
+
<option value="oauth">GitHub OAuth (logged-in user)</option>
|
|
36
|
+
<option value="token">Personal Access Token</option>
|
|
37
|
+
</select>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="form-row" id="copilot-token-row">
|
|
40
|
+
<label for="node-config-input-githubToken"><i class="fa fa-key"></i> Token</label>
|
|
41
|
+
<input type="password" id="node-config-input-githubToken" placeholder="ghp_...">
|
|
42
|
+
</div>
|
|
43
|
+
<div class="form-row">
|
|
44
|
+
<label for="node-config-input-cliPath"><i class="fa fa-terminal"></i> CLI Path</label>
|
|
45
|
+
<input type="text" id="node-config-input-cliPath" placeholder="(optional) /path/to/copilot">
|
|
46
|
+
</div>
|
|
47
|
+
<div class="form-row">
|
|
48
|
+
<label for="node-config-input-cliUrl"><i class="fa fa-link"></i> CLI URL</label>
|
|
49
|
+
<input type="text" id="node-config-input-cliUrl" placeholder="(optional) localhost:8080">
|
|
50
|
+
</div>
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<script type="text/html" data-help-name="copilot-config">
|
|
54
|
+
<p>Configuration node for GitHub Copilot.</p>
|
|
55
|
+
<h3>Properties</h3>
|
|
56
|
+
<dl class="message-properties">
|
|
57
|
+
<dt>Auth <span class="property-type">select</span></dt>
|
|
58
|
+
<dd>
|
|
59
|
+
<b>GitHub OAuth</b> — uses credentials stored by the Copilot CLI (<code>gh auth login</code>). No token needed.<br>
|
|
60
|
+
<b>Personal Access Token</b> — fallback for environments where OAuth isn't set up.
|
|
61
|
+
</dd>
|
|
62
|
+
<dt>Token <span class="property-type">password</span></dt>
|
|
63
|
+
<dd>Required when Auth is set to <em>Personal Access Token</em>. Stored as a credential and never sent to the browser.</dd>
|
|
64
|
+
<dt>CLI Path <span class="property-type">string</span></dt>
|
|
65
|
+
<dd>Optional override for the <code>copilot</code> CLI binary. Defaults to the bundled binary in <code>node_modules</code>.</dd>
|
|
66
|
+
<dt>CLI URL <span class="property-type">string</span></dt>
|
|
67
|
+
<dd>Optional URL of an existing Copilot CLI server (e.g. <code>localhost:8080</code>). When set, no CLI process is spawned.</dd>
|
|
68
|
+
</dl>
|
|
69
|
+
</script>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// @github/copilot-sdk is ESM-only — must be loaded with dynamic import(), not require().
|
|
4
|
+
// _sdk.load is exposed so tests can inject a mock without needing proxyquire.
|
|
5
|
+
const _sdk = {
|
|
6
|
+
load: () => import('@github/copilot-sdk'),
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// Resolve the copilot CLI binary bundled with @github/copilot, which is
|
|
10
|
+
// a dependency of @github/copilot-sdk. Using __dirname keeps this reliable
|
|
11
|
+
// regardless of how the module is loaded, and works inside Docker volumes.
|
|
12
|
+
function resolveBundledCliPath() {
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
// Walk up from this file through each node_modules search path until we find
|
|
16
|
+
// @github/copilot — works in dev (nested) and flat container installs alike.
|
|
17
|
+
let dir = __dirname;
|
|
18
|
+
while (true) {
|
|
19
|
+
const pkgJsonPath = path.join(dir, 'node_modules', '@github', 'copilot', 'package.json');
|
|
20
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
21
|
+
try {
|
|
22
|
+
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
|
23
|
+
const binEntry = pkgJson.bin && (pkgJson.bin.copilot || Object.values(pkgJson.bin)[0]);
|
|
24
|
+
if (binEntry) {
|
|
25
|
+
const resolved = path.resolve(path.dirname(pkgJsonPath), binEntry);
|
|
26
|
+
if (fs.existsSync(resolved)) return resolved;
|
|
27
|
+
}
|
|
28
|
+
} catch (_) { /* fall through */ }
|
|
29
|
+
}
|
|
30
|
+
const parent = path.dirname(dir);
|
|
31
|
+
if (parent === dir) break; // reached filesystem root
|
|
32
|
+
dir = parent;
|
|
33
|
+
}
|
|
34
|
+
return 'copilot'; // last resort: assume it's on PATH
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const BUNDLED_CLI_PATH = resolveBundledCliPath();
|
|
38
|
+
|
|
39
|
+
module.exports = function (RED) {
|
|
40
|
+
function CopilotConfigNode(config) {
|
|
41
|
+
RED.nodes.createNode(this, config);
|
|
42
|
+
this.authMethod = config.authMethod || 'oauth'; // 'oauth' | 'token'
|
|
43
|
+
this.cliPath = config.cliPath || undefined;
|
|
44
|
+
this.cliUrl = config.cliUrl || undefined;
|
|
45
|
+
// this.credentials.githubToken populated by Node-RED when authMethod === 'token'
|
|
46
|
+
this._client = null;
|
|
47
|
+
this._startPromise = null;
|
|
48
|
+
|
|
49
|
+
this.on('close', async (done) => {
|
|
50
|
+
if (this._client) {
|
|
51
|
+
try {
|
|
52
|
+
await this._client.stop();
|
|
53
|
+
} catch (err) {
|
|
54
|
+
this.warn('Error stopping CopilotClient: ' + err.message);
|
|
55
|
+
}
|
|
56
|
+
this._client = null;
|
|
57
|
+
this._startPromise = null;
|
|
58
|
+
}
|
|
59
|
+
done();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns a started CopilotClient, creating and starting it on first call.
|
|
65
|
+
* Subsequent calls return the same client (or wait for it to start).
|
|
66
|
+
*/
|
|
67
|
+
CopilotConfigNode.prototype.getClient = function () {
|
|
68
|
+
if (this._startPromise) {
|
|
69
|
+
return this._startPromise;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const options = {
|
|
73
|
+
autoStart: false,
|
|
74
|
+
autoRestart: true,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const token = this.credentials && this.credentials.githubToken;
|
|
78
|
+
if (this.authMethod === 'token' && token) {
|
|
79
|
+
// PAT fallback — explicit token takes priority, disables logged-in user auth
|
|
80
|
+
options.githubToken = token;
|
|
81
|
+
options.useLoggedInUser = false;
|
|
82
|
+
} else {
|
|
83
|
+
// Primary: use GitHub OAuth credentials stored by the Copilot CLI (gh auth)
|
|
84
|
+
options.useLoggedInUser = true;
|
|
85
|
+
}
|
|
86
|
+
if (this.cliPath) {
|
|
87
|
+
options.cliPath = this.cliPath;
|
|
88
|
+
} else if (!this.cliUrl) {
|
|
89
|
+
// Use the bundled CLI binary so the node works without copilot on PATH
|
|
90
|
+
options.cliPath = BUNDLED_CLI_PATH;
|
|
91
|
+
}
|
|
92
|
+
if (this.cliUrl) {
|
|
93
|
+
options.cliUrl = this.cliUrl;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Set _startPromise synchronously to prevent duplicate starts on concurrent calls
|
|
97
|
+
this._startPromise = _sdk.load().then(({ CopilotClient }) => {
|
|
98
|
+
this._client = new CopilotClient(options);
|
|
99
|
+
return this._client.start().then(() => this._client);
|
|
100
|
+
});
|
|
101
|
+
return this._startPromise;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
RED.nodes.registerType('copilot-config', CopilotConfigNode, {
|
|
105
|
+
credentials: {
|
|
106
|
+
githubToken: { type: 'password' },
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Admin endpoint: GET /copilot/models?configId=<id>
|
|
111
|
+
// Used by the prompt node editor to populate the model dropdown dynamically.
|
|
112
|
+
RED.httpAdmin.get('/copilot/models', RED.auth.needsPermission('copilot-config.read'), async (req, res) => {
|
|
113
|
+
const configNode = RED.nodes.getNode(req.query.configId);
|
|
114
|
+
if (!configNode) {
|
|
115
|
+
return res.status(404).json({ error: 'Config node not found' });
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const client = await configNode.getClient();
|
|
119
|
+
const models = await client.listModels();
|
|
120
|
+
res.json(models.map(m => ({
|
|
121
|
+
id: m.id,
|
|
122
|
+
multiplier: m.billing ? m.billing.multiplier : null,
|
|
123
|
+
})));
|
|
124
|
+
} catch (err) {
|
|
125
|
+
res.status(500).json({ error: err.message });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Exposed for testing — allows injecting a mock SDK without proxyquire
|
|
131
|
+
module.exports._sdk = _sdk;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('copilot-prompt', {
|
|
3
|
+
category: 'ai',
|
|
4
|
+
color: '#e8dff5',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: '' },
|
|
7
|
+
copilotConfig: { value: '', type: 'copilot-config', required: true },
|
|
8
|
+
model: { value: '' },
|
|
9
|
+
reasoningEffort: { value: '' },
|
|
10
|
+
timeout: { value: 60000 },
|
|
11
|
+
},
|
|
12
|
+
inputs: 1,
|
|
13
|
+
outputs: 2,
|
|
14
|
+
outputLabels: ['response', 'error'],
|
|
15
|
+
icon: 'copilot.svg',
|
|
16
|
+
label: function () {
|
|
17
|
+
return this.name || 'copilot';
|
|
18
|
+
},
|
|
19
|
+
paletteLabel: 'copilot',
|
|
20
|
+
oneditprepare: function () {
|
|
21
|
+
const nodeId = this.id;
|
|
22
|
+
const savedModel = this.model;
|
|
23
|
+
|
|
24
|
+
function loadModels(configId) {
|
|
25
|
+
if (!configId) return;
|
|
26
|
+
const $select = $('#node-input-model');
|
|
27
|
+
$select.empty().append($('<option>', { value: '', text: '— loading… —', disabled: true, selected: true }));
|
|
28
|
+
$.getJSON(`copilot/models?configId=${encodeURIComponent(configId)}`)
|
|
29
|
+
.done(function (models) {
|
|
30
|
+
$select.empty();
|
|
31
|
+
$select.append($('<option>', { value: '', text: '— select model —' }));
|
|
32
|
+
models.forEach(function (m) {
|
|
33
|
+
const cost = m.multiplier != null ? `(${m.multiplier}x)` : '';
|
|
34
|
+
$select.append($('<option>', { value: m.id, text: `${m.id} ${cost}`.trim() }));
|
|
35
|
+
});
|
|
36
|
+
if (savedModel) $select.val(savedModel);
|
|
37
|
+
if (!$select.val()) $select.prop('selectedIndex', 0);
|
|
38
|
+
})
|
|
39
|
+
.fail(function () {
|
|
40
|
+
$select.empty().append($('<option>', { value: savedModel || '', text: savedModel || '— unable to load —' }));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Load models whenever the config node selection changes
|
|
45
|
+
$('#node-input-copilotConfig').on('change', function () {
|
|
46
|
+
loadModels($(this).val());
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Initial load using the already-selected config node
|
|
50
|
+
loadModels($('#node-input-copilotConfig').val());
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<script type="text/html" data-template-name="copilot-prompt">
|
|
56
|
+
<div class="form-row">
|
|
57
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
58
|
+
<input type="text" id="node-input-name" placeholder="copilot">
|
|
59
|
+
</div>
|
|
60
|
+
<div class="form-row">
|
|
61
|
+
<label for="node-input-copilotConfig"><i class="fa fa-cog"></i> Config</label>
|
|
62
|
+
<input type="text" id="node-input-copilotConfig">
|
|
63
|
+
</div>
|
|
64
|
+
<div class="form-row">
|
|
65
|
+
<label for="node-input-model"><i class="fa fa-microchip"></i> Model</label>
|
|
66
|
+
<select id="node-input-model" style="width:70%">
|
|
67
|
+
<option value="">— select a config first —</option>
|
|
68
|
+
</select>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="form-row">
|
|
71
|
+
<label for="node-input-reasoningEffort"><i class="fa fa-brain"></i> Reasoning</label>
|
|
72
|
+
<select id="node-input-reasoningEffort">
|
|
73
|
+
<option value="">— default —</option>
|
|
74
|
+
<option value="low">low</option>
|
|
75
|
+
<option value="medium">medium</option>
|
|
76
|
+
<option value="high">high</option>
|
|
77
|
+
<option value="xhigh">xhigh</option>
|
|
78
|
+
</select>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="form-row">
|
|
81
|
+
<label for="node-input-timeout"><i class="fa fa-clock-o"></i> Timeout (ms)</label>
|
|
82
|
+
<input type="number" id="node-input-timeout" placeholder="60000" min="1000">
|
|
83
|
+
</div>
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<script type="text/html" data-help-name="copilot-prompt">
|
|
87
|
+
<p>Send a prompt (with optional attachments) to GitHub Copilot and receive a response.</p>
|
|
88
|
+
|
|
89
|
+
<h3>Inputs</h3>
|
|
90
|
+
<dl class="message-properties">
|
|
91
|
+
<dt>payload <span class="property-type">string | object</span></dt>
|
|
92
|
+
<dd>
|
|
93
|
+
A plain string is used as the prompt.<br>
|
|
94
|
+
An object may contain <code>{ prompt, attachments }</code>.
|
|
95
|
+
</dd>
|
|
96
|
+
<dt class="optional">attachments <span class="property-type">array</span></dt>
|
|
97
|
+
<dd>
|
|
98
|
+
Array of attachment descriptors. Each may be:<br>
|
|
99
|
+
<code>{ type: "file", path: "/abs/path" }</code><br>
|
|
100
|
+
<code>{ type: "base64", data: "...", name: "img.jpg" }</code><br>
|
|
101
|
+
<code>{ type: "buffer", data: Buffer, name: "file.bin" }</code>
|
|
102
|
+
</dd>
|
|
103
|
+
<dt class="optional">model <span class="property-type">string</span></dt>
|
|
104
|
+
<dd>Override the model for this message (e.g. <code>"gpt-5"</code>).</dd>
|
|
105
|
+
</dl>
|
|
106
|
+
|
|
107
|
+
<h3>Outputs</h3>
|
|
108
|
+
<ol class="node-ports">
|
|
109
|
+
<li>Response
|
|
110
|
+
<dl class="message-properties">
|
|
111
|
+
<dt>payload <span class="property-type">string</span></dt>
|
|
112
|
+
<dd>The assistant's response text.</dd>
|
|
113
|
+
<dt>sessionId <span class="property-type">string</span></dt>
|
|
114
|
+
<dd>The Copilot session ID.</dd>
|
|
115
|
+
<dt>events <span class="property-type">array</span></dt>
|
|
116
|
+
<dd>All session events emitted during the request.</dd>
|
|
117
|
+
</dl>
|
|
118
|
+
</li>
|
|
119
|
+
<li>Error
|
|
120
|
+
<dl class="message-properties">
|
|
121
|
+
<dt>payload <span class="property-type">string</span></dt>
|
|
122
|
+
<dd>Error message.</dd>
|
|
123
|
+
<dt>error <span class="property-type">Error</span></dt>
|
|
124
|
+
<dd>The error object.</dd>
|
|
125
|
+
</dl>
|
|
126
|
+
</li>
|
|
127
|
+
</ol>
|
|
128
|
+
</script>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MODEL = 'claude-haiku-4.5';
|
|
8
|
+
const DEFAULT_TIMEOUT = 60000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalises an attachment descriptor into an SDK-compatible `{ type, path }` object.
|
|
12
|
+
* Binary and base64 payloads are written to temp files; the caller is responsible
|
|
13
|
+
* for deleting them once the session send has completed.
|
|
14
|
+
*
|
|
15
|
+
* Supported input shapes:
|
|
16
|
+
* { type: 'file', path: '/abs/path' } → passed through
|
|
17
|
+
* { path: '/abs/path' } → shorthand for type=file
|
|
18
|
+
* { type: 'base64', data: '<b64 string>', name: '…' } → decoded to temp file
|
|
19
|
+
* { type: 'buffer', data: Buffer, name: '…' } → written to temp file
|
|
20
|
+
*
|
|
21
|
+
* Returns { sdkAttachment, tempFile } where tempFile is the path to clean up (or null).
|
|
22
|
+
*/
|
|
23
|
+
function normaliseAttachment(attachment) {
|
|
24
|
+
if (attachment.type === 'file' || (!attachment.type && attachment.path)) {
|
|
25
|
+
return { sdkAttachment: { type: 'file', path: attachment.path }, tempFile: null };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const tmpDir = os.tmpdir();
|
|
29
|
+
const name = attachment.name || 'attachment';
|
|
30
|
+
const tempFile = path.join(tmpDir, `nr-copilot-${Date.now()}-${Math.random().toString(36).slice(2)}-${name}`);
|
|
31
|
+
|
|
32
|
+
if (attachment.type === 'base64') {
|
|
33
|
+
fs.writeFileSync(tempFile, Buffer.from(attachment.data, 'base64'));
|
|
34
|
+
} else if (attachment.type === 'buffer') {
|
|
35
|
+
fs.writeFileSync(tempFile, attachment.data);
|
|
36
|
+
} else {
|
|
37
|
+
throw new Error(`Unsupported attachment type: "${attachment.type}"`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { sdkAttachment: { type: 'file', path: tempFile, displayName: name }, tempFile };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Cleans up a list of temp file paths, swallowing any errors.
|
|
45
|
+
*/
|
|
46
|
+
function cleanupTempFiles(tempFiles) {
|
|
47
|
+
for (const f of tempFiles) {
|
|
48
|
+
try { fs.unlinkSync(f); } catch (_) { /* ignore */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = function (RED) {
|
|
53
|
+
function CopilotPromptNode(config) {
|
|
54
|
+
RED.nodes.createNode(this, config);
|
|
55
|
+
this.configNodeId = config.copilotConfig;
|
|
56
|
+
this.model = config.model || DEFAULT_MODEL;
|
|
57
|
+
this.reasoningEffort = config.reasoningEffort || undefined;
|
|
58
|
+
this.timeout = parseInt(config.timeout, 10) || DEFAULT_TIMEOUT;
|
|
59
|
+
|
|
60
|
+
const node = this;
|
|
61
|
+
|
|
62
|
+
this.on('input', async function (msg, send, done) {
|
|
63
|
+
// Support both old (1-arg send) and new (2-arg) Node-RED APIs
|
|
64
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
65
|
+
done = done || function (err) { if (err) { node.error(err, msg); } };
|
|
66
|
+
|
|
67
|
+
node.status({ fill: 'blue', shape: 'dot', text: 'sending…' });
|
|
68
|
+
|
|
69
|
+
// --- Resolve config node ---
|
|
70
|
+
const configNode = RED.nodes.getNode(node.configNodeId);
|
|
71
|
+
if (!configNode) {
|
|
72
|
+
node.status({ fill: 'red', shape: 'ring', text: 'no config' });
|
|
73
|
+
const err = new Error('copilot-prompt: no copilot-config node configured');
|
|
74
|
+
send([null, { payload: err.message, error: err, _msgid: msg._msgid }]);
|
|
75
|
+
return done();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Parse prompt + attachments from msg ---
|
|
79
|
+
let prompt;
|
|
80
|
+
let rawAttachments = [];
|
|
81
|
+
|
|
82
|
+
if (typeof msg.payload === 'string') {
|
|
83
|
+
prompt = msg.payload;
|
|
84
|
+
} else if (msg.payload && typeof msg.payload === 'object') {
|
|
85
|
+
prompt = msg.payload.prompt || '';
|
|
86
|
+
rawAttachments = msg.payload.attachments || [];
|
|
87
|
+
} else {
|
|
88
|
+
prompt = String(msg.payload || '');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// msg.attachments can supplement / override
|
|
92
|
+
if (Array.isArray(msg.attachments)) {
|
|
93
|
+
rawAttachments = rawAttachments.concat(msg.attachments);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Model can be overridden per-message
|
|
97
|
+
const model = msg.model || node.model;
|
|
98
|
+
|
|
99
|
+
// --- Normalise attachments ---
|
|
100
|
+
const tempFiles = [];
|
|
101
|
+
let sdkAttachments;
|
|
102
|
+
try {
|
|
103
|
+
sdkAttachments = rawAttachments.map((a) => {
|
|
104
|
+
const { sdkAttachment, tempFile } = normaliseAttachment(a);
|
|
105
|
+
if (tempFile) tempFiles.push(tempFile);
|
|
106
|
+
return sdkAttachment;
|
|
107
|
+
});
|
|
108
|
+
} catch (err) {
|
|
109
|
+
cleanupTempFiles(tempFiles);
|
|
110
|
+
node.status({ fill: 'red', shape: 'ring', text: 'attachment error' });
|
|
111
|
+
send([null, { payload: err.message, error: err, _msgid: msg._msgid }]);
|
|
112
|
+
return done();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- Build session config ---
|
|
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 ---
|
|
123
|
+
let client;
|
|
124
|
+
let session;
|
|
125
|
+
try {
|
|
126
|
+
client = await configNode.getClient();
|
|
127
|
+
session = await client.createSession(sessionConfig);
|
|
128
|
+
|
|
129
|
+
const messageOptions = { prompt };
|
|
130
|
+
if (sdkAttachments.length > 0) {
|
|
131
|
+
messageOptions.attachments = sdkAttachments;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const events = [];
|
|
135
|
+
session.on((event) => events.push(event));
|
|
136
|
+
|
|
137
|
+
const response = await session.sendAndWait(messageOptions, node.timeout);
|
|
138
|
+
|
|
139
|
+
cleanupTempFiles(tempFiles);
|
|
140
|
+
await session.destroy();
|
|
141
|
+
|
|
142
|
+
const responseText = response ? response.data.content : '';
|
|
143
|
+
node.status({ fill: 'green', shape: 'dot', text: 'done' });
|
|
144
|
+
|
|
145
|
+
msg.payload = responseText;
|
|
146
|
+
msg.sessionId = session.sessionId;
|
|
147
|
+
msg.events = events;
|
|
148
|
+
send([msg, null]);
|
|
149
|
+
done();
|
|
150
|
+
} catch (err) {
|
|
151
|
+
cleanupTempFiles(tempFiles);
|
|
152
|
+
if (session) {
|
|
153
|
+
try { await session.destroy(); } catch (_) { /* ignore */ }
|
|
154
|
+
}
|
|
155
|
+
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
156
|
+
send([null, { payload: err.message, error: err, _msgid: msg._msgid }]);
|
|
157
|
+
done();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
RED.nodes.registerType('copilot-prompt', CopilotPromptNode);
|
|
163
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
|
2
|
+
<!-- Copilot icon from github/primer/octicons — MIT License -->
|
|
3
|
+
<defs>
|
|
4
|
+
<linearGradient id="cg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
5
|
+
<stop offset="0%" style="stop-color:#a371f7"/>
|
|
6
|
+
<stop offset="100%" style="stop-color:#388bfd"/>
|
|
7
|
+
</linearGradient>
|
|
8
|
+
</defs>
|
|
9
|
+
<path fill="url(#cg)" d="M23.922 16.992c-.861 1.495-5.859 5.023-11.922 5.023-6.063 0-11.061-3.528-11.922-5.023A.641.641 0 0 1 0 16.736v-2.869a.841.841 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.195 10.195 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952 1.399-1.136 3.392-2.093 6.122-2.093 2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.832.832 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256ZM12.172 11h-.344a4.323 4.323 0 0 1-.355.508C10.703 12.455 9.555 13 7.965 13c-1.725 0-2.989-.359-3.782-1.259a2.005 2.005 0 0 1-.085-.104L4 11.741v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.323 4.323 0 0 1-.355-.508h-.016.016Zm.641-2.935c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z"/>
|
|
10
|
+
<path fill="url(#cg)" d="M14.5 14.25a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0v-2a1 1 0 0 1 1-1Zm-5 0a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0v-2a1 1 0 0 1 1-1Z"/>
|
|
11
|
+
</svg>
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-red-contrib-copilot",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Node-RED nodes for GitHub Copilot via @github/copilot-sdk",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "George Talusan",
|
|
7
|
+
"type": "commonjs",
|
|
8
|
+
"main": "index.js",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"node-red",
|
|
11
|
+
"copilot",
|
|
12
|
+
"github",
|
|
13
|
+
"ai"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "mocha 'test/unit/**/*_spec.js' --timeout 10000",
|
|
17
|
+
"test:integration": "mocha 'test/integration/**/*_spec.js' --timeout 120000"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=24.0.0",
|
|
21
|
+
"npm": ">=10.0.0"
|
|
22
|
+
},
|
|
23
|
+
"node-red": {
|
|
24
|
+
"version": ">=3.0.0",
|
|
25
|
+
"nodes": {
|
|
26
|
+
"copilot-config": "nodes/copilot-config/copilot-config.js",
|
|
27
|
+
"copilot-prompt": "nodes/copilot-prompt/copilot-prompt.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@github/copilot-sdk": "0.1.30"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"mocha": "^10.8.2",
|
|
35
|
+
"node-red": "^4.1.7",
|
|
36
|
+
"node-red-node-test-helper": "^0.3.6",
|
|
37
|
+
"proxyquire": "^2.1.3",
|
|
38
|
+
"sinon": "^17.0.1"
|
|
39
|
+
}
|
|
40
|
+
}
|