relmio 0.8.0 → 0.9.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/CHANGELOG.md +39 -0
- package/README.md +50 -0
- package/docs/ai-assistant.md +230 -0
- package/docs/brand.md +14 -13
- package/docs/images/brand/relmio-logo-rounded.svg +25 -0
- package/docs/images/brand/relmio-logo.png +0 -0
- package/docs/maintenance.md +61 -0
- package/docs/security.md +43 -0
- package/docs/vps-and-n8n.md +3 -0
- package/package.json +1 -1
- package/src/browser.js +3 -2
- package/src/cli.js +21 -8
- package/src/domain/assistant-templates.js +207 -0
- package/src/domain/assistant.js +363 -0
- package/src/domain/safety.js +4 -1
- package/src/services/assistant-installer.js +514 -0
- package/src/services/discovery.js +28 -1
- package/src/services/installer.js +13 -5
- package/src/ui/assistant.css +154 -0
- package/src/ui/assistant.html +351 -0
- package/src/ui/assistant.js +290 -0
- package/src/ui/index.html +7 -12
- package/src/ui/local.html +7 -12
- package/src/ui/relmio-icon-rounded.svg +25 -0
- package/src/ui/relmio-icon.png +0 -0
- package/src/ui/styles.css +0 -4
- package/src/web/server.js +261 -26
- package/docs/images/brand/relmio-mark.svg +0 -10
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
const token = new URLSearchParams(window.location.search).get("session");
|
|
2
|
+
window.history.replaceState(null, "", window.location.pathname);
|
|
3
|
+
|
|
4
|
+
const state = {
|
|
5
|
+
fingerprint: null,
|
|
6
|
+
network: null,
|
|
7
|
+
reviewedIncludeSearxng: null,
|
|
8
|
+
installAttempted: false,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const element = (id) => document.getElementById(id);
|
|
12
|
+
const message = element("global-message-text");
|
|
13
|
+
const errorBox = element("global-error");
|
|
14
|
+
const errorMessage = element("global-error-text");
|
|
15
|
+
|
|
16
|
+
function setMessage(value) {
|
|
17
|
+
message.textContent = value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function clearError() {
|
|
21
|
+
errorBox.hidden = true;
|
|
22
|
+
errorMessage.textContent = "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function showError(error) {
|
|
26
|
+
errorMessage.textContent = error?.message ?? "The request could not be completed.";
|
|
27
|
+
errorBox.hidden = false;
|
|
28
|
+
errorBox.focus();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setBusy(button, busy, busyText) {
|
|
32
|
+
if (!button.dataset.label) button.dataset.label = button.textContent;
|
|
33
|
+
button.disabled = busy;
|
|
34
|
+
button.setAttribute("aria-busy", String(busy));
|
|
35
|
+
button.textContent = busy ? busyText : button.dataset.label;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function showStep(step) {
|
|
39
|
+
document.body.dataset.currentStep = String(step);
|
|
40
|
+
let activePanel = null;
|
|
41
|
+
for (const panel of document.querySelectorAll("[data-step]")) {
|
|
42
|
+
const active = Number(panel.dataset.step) === step;
|
|
43
|
+
panel.hidden = !active;
|
|
44
|
+
if (active) activePanel = panel;
|
|
45
|
+
}
|
|
46
|
+
for (const marker of document.querySelectorAll("[data-step-marker]")) {
|
|
47
|
+
const active = Number(marker.dataset.stepMarker) === step;
|
|
48
|
+
marker.toggleAttribute("aria-current", active);
|
|
49
|
+
}
|
|
50
|
+
if (activePanel) activePanel.scrollTop = 0;
|
|
51
|
+
activePanel?.querySelector("h2")?.focus({ preventScroll: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resetFingerprint() {
|
|
55
|
+
state.fingerprint = null;
|
|
56
|
+
element("fingerprint-box").hidden = true;
|
|
57
|
+
element("fingerprint-confirm").checked = false;
|
|
58
|
+
element("password").value = "";
|
|
59
|
+
element("password").disabled = true;
|
|
60
|
+
updateConnectState();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function updateConnectState() {
|
|
64
|
+
element("connect-button").disabled = !(
|
|
65
|
+
state.fingerprint &&
|
|
66
|
+
element("fingerprint-confirm").checked &&
|
|
67
|
+
element("privileged-confirm").checked
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fillSelect(select, items, selectedValue) {
|
|
72
|
+
select.replaceChildren();
|
|
73
|
+
for (const item of items) {
|
|
74
|
+
const option = document.createElement("option");
|
|
75
|
+
option.value = item.value;
|
|
76
|
+
option.textContent = item.label;
|
|
77
|
+
option.selected = item.value === selectedValue;
|
|
78
|
+
select.append(option);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatInstanceAiStatus(instanceAi) {
|
|
83
|
+
switch (instanceAi?.status) {
|
|
84
|
+
case "enabled":
|
|
85
|
+
return "AI Assistant prerequisite enabled — N8N_ENABLED_MODULES includes instance-ai. Fresh rediscovery is required after n8n changes.";
|
|
86
|
+
case "configured":
|
|
87
|
+
return "AI Assistant prerequisite not met — N8N_ENABLED_MODULES is set but does not include instance-ai. Append instance-ai as a distinct comma-delimited token while preserving existing module entries, then redeploy or restart n8n outside this wizard.";
|
|
88
|
+
case "missing":
|
|
89
|
+
return "AI Assistant prerequisite not met — add N8N_ENABLED_MODULES=instance-ai to the existing n8n service, then redeploy or restart n8n outside this wizard.";
|
|
90
|
+
default:
|
|
91
|
+
return "AI Assistant prerequisite could not be verified for this n8n container.";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function api(path, { method = "GET", body } = {}) {
|
|
96
|
+
if (!token) throw new Error("The setup session is missing. Start the assistant command again.");
|
|
97
|
+
const response = await fetch(path, {
|
|
98
|
+
method,
|
|
99
|
+
headers: {
|
|
100
|
+
"Content-Type": "application/json",
|
|
101
|
+
"X-Setup-Token": token,
|
|
102
|
+
},
|
|
103
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
104
|
+
});
|
|
105
|
+
let result;
|
|
106
|
+
try {
|
|
107
|
+
result = await response.json();
|
|
108
|
+
} catch {
|
|
109
|
+
throw new Error("The wizard returned an unreadable response. Start again.");
|
|
110
|
+
}
|
|
111
|
+
if (!response.ok) throw new Error(result?.error ?? "The request failed.");
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function loadNetworks() {
|
|
116
|
+
const containerName = element("container-select").value;
|
|
117
|
+
const result = await api("/api/networks", {
|
|
118
|
+
method: "POST",
|
|
119
|
+
body: { containerName },
|
|
120
|
+
});
|
|
121
|
+
state.network = result;
|
|
122
|
+
const instanceAiStatus = element("instance-ai-status");
|
|
123
|
+
instanceAiStatus.textContent = formatInstanceAiStatus(result.instanceAi);
|
|
124
|
+
instanceAiStatus.dataset.status = result.instanceAi?.status ?? "unknown";
|
|
125
|
+
const prerequisiteReady = result.instanceAi?.status === "enabled";
|
|
126
|
+
element("review-button").disabled = !prerequisiteReady;
|
|
127
|
+
element("review-readiness").textContent = prerequisiteReady
|
|
128
|
+
? "Prerequisite verified. Review the exact companion and SearXNG selection before any write."
|
|
129
|
+
: "Enable instance-ai, restart or redeploy n8n, then reconnect to Relmio before reviewing a plan.";
|
|
130
|
+
fillSelect(
|
|
131
|
+
element("network-select"),
|
|
132
|
+
result.networks.map((network) => ({ value: network, label: network })),
|
|
133
|
+
result.recommended,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function discover() {
|
|
138
|
+
const result = await api("/api/discover", { method: "POST", body: {} });
|
|
139
|
+
if (!Array.isArray(result.containers) || result.containers.length === 0) {
|
|
140
|
+
throw new Error("No running official n8n container was found.");
|
|
141
|
+
}
|
|
142
|
+
element("docker-version").textContent = result.dockerVersion;
|
|
143
|
+
element("compose-version").textContent = result.composeVersion;
|
|
144
|
+
fillSelect(
|
|
145
|
+
element("container-select"),
|
|
146
|
+
result.containers.map((container) => ({
|
|
147
|
+
value: container.name,
|
|
148
|
+
label: `${container.name} - ${container.image}`,
|
|
149
|
+
})),
|
|
150
|
+
result.containers[0].name,
|
|
151
|
+
);
|
|
152
|
+
await loadNetworks();
|
|
153
|
+
showStep(2);
|
|
154
|
+
setMessage("n8n was found. Choose an existing Docker network.");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
element("fingerprint-button").addEventListener("click", async (event) => {
|
|
158
|
+
const button = event.currentTarget;
|
|
159
|
+
clearError();
|
|
160
|
+
setBusy(button, true, "Checking identity…");
|
|
161
|
+
try {
|
|
162
|
+
const result = await api("/api/ssh/fingerprint", {
|
|
163
|
+
method: "POST",
|
|
164
|
+
body: { host: element("host").value, port: element("port").value },
|
|
165
|
+
});
|
|
166
|
+
state.fingerprint = result.fingerprint;
|
|
167
|
+
element("fingerprint-value").textContent = result.fingerprint;
|
|
168
|
+
element("fingerprint-box").hidden = false;
|
|
169
|
+
setMessage("Confirm the VPS identity before supplying its password.");
|
|
170
|
+
} catch (error) {
|
|
171
|
+
showError(error);
|
|
172
|
+
} finally {
|
|
173
|
+
setBusy(button, false);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
element("fingerprint-confirm").addEventListener("change", (event) => {
|
|
178
|
+
element("password").disabled = !event.currentTarget.checked;
|
|
179
|
+
if (!event.currentTarget.checked) element("password").value = "";
|
|
180
|
+
updateConnectState();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
element("privileged-confirm").addEventListener("change", updateConnectState);
|
|
184
|
+
element("host").addEventListener("input", resetFingerprint);
|
|
185
|
+
element("port").addEventListener("input", resetFingerprint);
|
|
186
|
+
|
|
187
|
+
element("vps-form").addEventListener("submit", async (event) => {
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
const button = element("connect-button");
|
|
190
|
+
clearError();
|
|
191
|
+
setBusy(button, true, "Connecting safely…");
|
|
192
|
+
try {
|
|
193
|
+
await api("/api/ssh/connect", {
|
|
194
|
+
method: "POST",
|
|
195
|
+
body: {
|
|
196
|
+
host: element("host").value,
|
|
197
|
+
port: element("port").value,
|
|
198
|
+
username: element("username").value,
|
|
199
|
+
password: element("password").value,
|
|
200
|
+
expectedFingerprint: state.fingerprint,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
element("password").value = "";
|
|
204
|
+
await discover();
|
|
205
|
+
} catch (error) {
|
|
206
|
+
element("password").value = "";
|
|
207
|
+
showError(error);
|
|
208
|
+
} finally {
|
|
209
|
+
setBusy(button, false);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
element("container-select").addEventListener("change", () => {
|
|
214
|
+
loadNetworks().catch(showError);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
element("review-button").addEventListener("click", async (event) => {
|
|
218
|
+
const button = event.currentTarget;
|
|
219
|
+
clearError();
|
|
220
|
+
setBusy(button, true, "Preparing plan…");
|
|
221
|
+
try {
|
|
222
|
+
const networkName = element("network-select").value;
|
|
223
|
+
const includeSearxng = element("include-searxng").checked;
|
|
224
|
+
const plan = await api("/api/assistant/plan", {
|
|
225
|
+
method: "POST",
|
|
226
|
+
body: {
|
|
227
|
+
containerName: element("container-select").value,
|
|
228
|
+
networkName,
|
|
229
|
+
includeSearxng,
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
state.reviewedIncludeSearxng = plan.includeSearxng;
|
|
233
|
+
element("review-network").textContent = networkName;
|
|
234
|
+
element("review-instance-ai").textContent = formatInstanceAiStatus(plan.instanceAi);
|
|
235
|
+
element("review-web-search").textContent = plan.includeSearxng
|
|
236
|
+
? `Attach optional SearXNG web search to ${networkName} with its private alias`
|
|
237
|
+
: "Web search disabled — no SearXNG service, settings file, or URL will be installed";
|
|
238
|
+
element("install-confirm").checked = false;
|
|
239
|
+
element("install-button").disabled = true;
|
|
240
|
+
showStep(3);
|
|
241
|
+
setMessage("Review the privileged companion plan. The VPS has not changed.");
|
|
242
|
+
} catch (error) {
|
|
243
|
+
showError(error);
|
|
244
|
+
} finally {
|
|
245
|
+
setBusy(button, false);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
element("install-confirm").addEventListener("change", (event) => {
|
|
250
|
+
element("install-button").disabled = !event.currentTarget.checked;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
element("include-searxng").addEventListener("change", () => {
|
|
254
|
+
state.reviewedIncludeSearxng = null;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
element("install-button").addEventListener("click", async (event) => {
|
|
258
|
+
const button = event.currentTarget;
|
|
259
|
+
clearError();
|
|
260
|
+
setBusy(button, true, "Starting companion…");
|
|
261
|
+
state.installAttempted = true;
|
|
262
|
+
try {
|
|
263
|
+
const result = await api("/api/assistant/install", {
|
|
264
|
+
method: "POST",
|
|
265
|
+
body: {
|
|
266
|
+
containerName: element("container-select").value,
|
|
267
|
+
networkName: element("network-select").value,
|
|
268
|
+
includeSearxng: state.reviewedIncludeSearxng,
|
|
269
|
+
confirmed: element("install-confirm").checked,
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
element("sandbox-url").textContent = result.sandboxUrl;
|
|
273
|
+
element("sandbox-api-key").textContent = typeof result.sandboxApiKey === "string"
|
|
274
|
+
? result.sandboxApiKey
|
|
275
|
+
: "Unchanged on this managed update; the key is not redisplayed.";
|
|
276
|
+
if (result.includeSearxng === true && typeof result.searxngUrl === "string") {
|
|
277
|
+
element("searxng-url").textContent = result.searxngUrl;
|
|
278
|
+
element("searxng-note").textContent = "SearXNG uses its private server secret internally. There is no user-facing SearXNG key.";
|
|
279
|
+
} else {
|
|
280
|
+
element("searxng-url").textContent = "Web search disabled";
|
|
281
|
+
element("searxng-note").textContent = "Web search was disabled, so no SearXNG service or settings file was installed. Its retained private secret is never user-facing.";
|
|
282
|
+
}
|
|
283
|
+
showStep(4);
|
|
284
|
+
setMessage("Companion verified. Enter the values directly in n8n’s AI Assistant settings.");
|
|
285
|
+
} catch (error) {
|
|
286
|
+
showError(error);
|
|
287
|
+
} finally {
|
|
288
|
+
setBusy(button, false);
|
|
289
|
+
}
|
|
290
|
+
});
|
package/src/ui/index.html
CHANGED
|
@@ -8,10 +8,7 @@
|
|
|
8
8
|
content="Relmio connects compatible AI clients to a private ChatGPT/Codex OAuth sidecar, starting with self-hosted n8n."
|
|
9
9
|
/>
|
|
10
10
|
<title>Relmio | n8n Setup</title>
|
|
11
|
-
<link
|
|
12
|
-
rel="icon"
|
|
13
|
-
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath fill='%23137c74' d='M7 8h19l31 24H41L26 20v10L7 17Z'/%3E%3Cpath fill='%2312211f' d='M7 47l19-13v10l15-12h16L26 56H7Z'/%3E%3C/svg%3E"
|
|
14
|
-
/>
|
|
11
|
+
<link rel="icon" href="/relmio-icon-rounded.svg" type="image/svg+xml" />
|
|
15
12
|
<link rel="stylesheet" href="/styles.css" />
|
|
16
13
|
<script src="/theme.js" type="module"></script>
|
|
17
14
|
<script src="/app.js" type="module"></script>
|
|
@@ -21,15 +18,13 @@
|
|
|
21
18
|
|
|
22
19
|
<header class="site-header">
|
|
23
20
|
<div class="brand">
|
|
24
|
-
<
|
|
21
|
+
<img
|
|
25
22
|
class="brand-mark"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
<path fill="#12211f" d="M7 47l19-13v10l15-12h16L26 56H7Z" />
|
|
32
|
-
</svg>
|
|
23
|
+
src="/relmio-icon.png"
|
|
24
|
+
alt=""
|
|
25
|
+
width="28"
|
|
26
|
+
height="28"
|
|
27
|
+
/>
|
|
33
28
|
<span>Relmio</span>
|
|
34
29
|
</div>
|
|
35
30
|
<div class="header-actions">
|
package/src/ui/local.html
CHANGED
|
@@ -8,10 +8,7 @@
|
|
|
8
8
|
content="Install a loopback-only Relmio endpoint for the OpenAI API or the official Codex App Server."
|
|
9
9
|
/>
|
|
10
10
|
<title>Relmio | Local Endpoint Setup</title>
|
|
11
|
-
<link
|
|
12
|
-
rel="icon"
|
|
13
|
-
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath fill='%23137c74' d='M8 13 28 5v38c0 4-2 7-6 9L8 59Z'/%3E%3Cpath fill='%2312211f' d='M36 5l11 5c6 3 9 8 9 15v9c0 3 1 5 3 7l3 3c3 3 3 8 0 11s-8 3-11 0L40 44c-3-3-4-6-4-10Z'/%3E%3C/svg%3E"
|
|
14
|
-
/>
|
|
11
|
+
<link rel="icon" href="/relmio-icon-rounded.svg" type="image/svg+xml" />
|
|
15
12
|
<link rel="stylesheet" href="/styles.css" />
|
|
16
13
|
<link rel="stylesheet" href="/local.css" />
|
|
17
14
|
<script src="/theme.js" type="module"></script>
|
|
@@ -22,15 +19,13 @@
|
|
|
22
19
|
|
|
23
20
|
<header class="site-header">
|
|
24
21
|
<div class="brand">
|
|
25
|
-
<
|
|
22
|
+
<img
|
|
26
23
|
class="brand-mark"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
<path fill="#12211f" d="M36 5l11 5c6 3 9 8 9 15v9c0 3 1 5 3 7l3 3c3 3 3 8 0 11s-8 3-11 0L40 44c-3-3-4-6-4-10Z" />
|
|
33
|
-
</svg>
|
|
24
|
+
src="/relmio-icon.png"
|
|
25
|
+
alt=""
|
|
26
|
+
width="28"
|
|
27
|
+
height="28"
|
|
28
|
+
/>
|
|
34
29
|
<span>Relmio</span>
|
|
35
30
|
</div>
|
|
36
31
|
<div class="header-actions">
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title">
|
|
3
|
+
<title id="title">Relmio</title>
|
|
4
|
+
<defs>
|
|
5
|
+
<clipPath id="rounded-square">
|
|
6
|
+
<rect width="512" height="512" rx="112" />
|
|
7
|
+
</clipPath>
|
|
8
|
+
<linearGradient id="sage" x1="0" y1="0" x2="1" y2="1">
|
|
9
|
+
<stop offset="0" stop-color="#b0c0a6" />
|
|
10
|
+
<stop offset="1" stop-color="#879f87" />
|
|
11
|
+
</linearGradient>
|
|
12
|
+
<linearGradient id="teal" x1="0" y1="0" x2="0" y2="1">
|
|
13
|
+
<stop offset="0" stop-color="#12938f" />
|
|
14
|
+
<stop offset="1" stop-color="#047c79" />
|
|
15
|
+
</linearGradient>
|
|
16
|
+
</defs>
|
|
17
|
+
<g clip-path="url(#rounded-square)">
|
|
18
|
+
<rect width="512" height="512" fill="url(#sage)" />
|
|
19
|
+
<rect x="126" y="70" width="430" height="560" rx="215" fill="#f6f0e9" />
|
|
20
|
+
<rect x="190" y="218" width="194" height="390" rx="97" fill="url(#teal)" />
|
|
21
|
+
<rect x="190" y="315" width="194" height="293" fill="url(#teal)" />
|
|
22
|
+
<circle cx="242" cy="292" r="20" fill="#f6f0e9" />
|
|
23
|
+
<circle cx="332" cy="292" r="20" fill="#f6f0e9" />
|
|
24
|
+
</g>
|
|
25
|
+
</svg>
|
|
Binary file
|
package/src/ui/styles.css
CHANGED
|
@@ -1126,15 +1126,11 @@ footer p {
|
|
|
1126
1126
|
/* ---------- Dark mode refinement ---------- */
|
|
1127
1127
|
|
|
1128
1128
|
:root[data-theme="dark"] .brand-mark {
|
|
1129
|
-
padding: 0.125rem;
|
|
1130
|
-
background: #f7f8f5;
|
|
1131
1129
|
box-shadow: 0 0 0 1px rgb(255 255 255 / 14%);
|
|
1132
1130
|
}
|
|
1133
1131
|
|
|
1134
1132
|
@media (prefers-color-scheme: dark) {
|
|
1135
1133
|
:root:not([data-theme]) .brand-mark {
|
|
1136
|
-
padding: 0.125rem;
|
|
1137
|
-
background: #f7f8f5;
|
|
1138
1134
|
box-shadow: 0 0 0 1px rgb(255 255 255 / 14%);
|
|
1139
1135
|
}
|
|
1140
1136
|
}
|