relmio 0.6.0 → 0.8.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 +51 -0
- package/README.md +54 -436
- package/docs/brand.md +5 -4
- package/docs/faq.md +45 -0
- package/docs/getting-started.md +38 -0
- package/docs/images/brand/relmio-mark.svg +2 -8
- package/docs/local-endpoints-spec.md +27 -0
- package/docs/local-endpoints.md +72 -11
- package/docs/reference.md +94 -0
- package/docs/security.md +34 -0
- package/docs/troubleshooting.md +33 -0
- package/docs/vps-and-n8n.md +30 -0
- package/package.json +1 -1
- package/src/gateway/codex-chat.js +103 -2
- package/src/services/local-chat-test.js +501 -0
- package/src/ui/local.css +196 -1
- package/src/ui/local.html +113 -5
- package/src/ui/local.js +395 -1
- package/src/web/server.js +203 -1
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import {
|
|
2
|
+
constants,
|
|
3
|
+
generateKeyPair as generateKeyPairCallback,
|
|
4
|
+
privateDecrypt,
|
|
5
|
+
randomUUID,
|
|
6
|
+
} from "node:crypto";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
const generateKeyPair = promisify(generateKeyPairCallback);
|
|
10
|
+
|
|
11
|
+
const MAX_SESSIONS = 8;
|
|
12
|
+
const KEY_TTL_MS = 5 * 60 * 1_000;
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 120_000;
|
|
14
|
+
const MAX_ENDPOINT_LENGTH = 64;
|
|
15
|
+
const MAX_CIPHERTEXT_LENGTH = 4_096;
|
|
16
|
+
const MAX_INPUT_LENGTH = 8_192;
|
|
17
|
+
const MAX_CONVERSATION_ID_LENGTH = 160;
|
|
18
|
+
const MAX_RESPONSE_BYTES = 512 * 1_024;
|
|
19
|
+
const MAX_OUTPUT_LENGTH = 12 * 1_024;
|
|
20
|
+
|
|
21
|
+
function requestError(message, statusCode = 400) {
|
|
22
|
+
return Object.assign(new Error(message), { statusCode });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function expiredKeyError() {
|
|
26
|
+
return requestError(
|
|
27
|
+
"This test credential has expired or was forgotten. Secure it again.",
|
|
28
|
+
409,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function adapterError(statusCode = 502) {
|
|
33
|
+
return requestError("The local adapter test could not be completed.", statusCode);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isPlainObject(value) {
|
|
37
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isBoundedText(value, maximumLength) {
|
|
41
|
+
return (
|
|
42
|
+
typeof value === "string" &&
|
|
43
|
+
value.length > 0 &&
|
|
44
|
+
value.length <= maximumLength &&
|
|
45
|
+
!/[\u0000-\u001f\u007f]/u.test(value)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseLocalAdapterBaseUrl(value) {
|
|
50
|
+
if (typeof value !== "string" || value.length > MAX_ENDPOINT_LENGTH) {
|
|
51
|
+
throw requestError("Enter a valid local adapter address.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const match = /^http:\/\/127\.0\.0\.1:([1-9]\d{0,4})\/?$/u.exec(value);
|
|
55
|
+
if (!match) {
|
|
56
|
+
throw requestError("Enter a valid local adapter address.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const port = Number(match[1]);
|
|
60
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
|
|
61
|
+
throw requestError("Enter a valid local adapter address.");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return `http://127.0.0.1:${port}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateMessageRequest(request) {
|
|
68
|
+
if (!isPlainObject(request)) {
|
|
69
|
+
throw requestError("Enter a valid local adapter test request.");
|
|
70
|
+
}
|
|
71
|
+
const endpointBaseUrl = parseLocalAdapterBaseUrl(request.endpointBaseUrl);
|
|
72
|
+
if (!isBoundedText(request.keyId, 128)) {
|
|
73
|
+
throw expiredKeyError();
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
typeof request.encryptedCredential !== "string" ||
|
|
77
|
+
request.encryptedCredential.length < 32 ||
|
|
78
|
+
request.encryptedCredential.length > MAX_CIPHERTEXT_LENGTH ||
|
|
79
|
+
!/^[A-Za-z0-9+/]+={0,2}$/u.test(request.encryptedCredential)
|
|
80
|
+
) {
|
|
81
|
+
throw requestError("Secure the client credential again before testing.");
|
|
82
|
+
}
|
|
83
|
+
if (!isBoundedText(request.input, MAX_INPUT_LENGTH)) {
|
|
84
|
+
throw requestError("Enter a shorter chat message.");
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
request.conversationId !== undefined &&
|
|
88
|
+
!isBoundedText(request.conversationId, MAX_CONVERSATION_ID_LENGTH)
|
|
89
|
+
) {
|
|
90
|
+
throw requestError("Start a new conversation and try again.");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
endpointBaseUrl,
|
|
95
|
+
keyId: request.keyId,
|
|
96
|
+
encryptedCredential: request.encryptedCredential,
|
|
97
|
+
input: request.input,
|
|
98
|
+
...(request.conversationId !== undefined
|
|
99
|
+
? { conversationId: request.conversationId }
|
|
100
|
+
: {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function decodeCiphertext(value) {
|
|
105
|
+
const decoded = Buffer.from(value, "base64");
|
|
106
|
+
if (
|
|
107
|
+
decoded.length === 0 ||
|
|
108
|
+
decoded.toString("base64") !== value
|
|
109
|
+
) {
|
|
110
|
+
decoded.fill(0);
|
|
111
|
+
throw requestError("Secure the client credential again before testing.");
|
|
112
|
+
}
|
|
113
|
+
return decoded;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function readBoundedResponse(response) {
|
|
117
|
+
const contentLength = response.headers?.get?.("content-length");
|
|
118
|
+
if (
|
|
119
|
+
contentLength !== null &&
|
|
120
|
+
contentLength !== undefined &&
|
|
121
|
+
(!/^\d+$/u.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES)
|
|
122
|
+
) {
|
|
123
|
+
throw adapterError();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const reader = response.body?.getReader?.();
|
|
127
|
+
if (!reader) {
|
|
128
|
+
throw adapterError();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const chunks = [];
|
|
132
|
+
let bytes = 0;
|
|
133
|
+
try {
|
|
134
|
+
for (;;) {
|
|
135
|
+
const { done, value } = await reader.read();
|
|
136
|
+
if (done) {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
const chunk = Buffer.from(value);
|
|
140
|
+
bytes += chunk.length;
|
|
141
|
+
if (bytes > MAX_RESPONSE_BYTES) {
|
|
142
|
+
chunk.fill(0);
|
|
143
|
+
throw adapterError();
|
|
144
|
+
}
|
|
145
|
+
chunks.push(chunk);
|
|
146
|
+
}
|
|
147
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
148
|
+
} finally {
|
|
149
|
+
for (const chunk of chunks) {
|
|
150
|
+
chunk.fill(0);
|
|
151
|
+
}
|
|
152
|
+
reader.releaseLock?.();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseAdapterResponse(text) {
|
|
157
|
+
let value;
|
|
158
|
+
try {
|
|
159
|
+
value = JSON.parse(text);
|
|
160
|
+
} catch {
|
|
161
|
+
throw adapterError();
|
|
162
|
+
}
|
|
163
|
+
if (
|
|
164
|
+
!isPlainObject(value) ||
|
|
165
|
+
!isBoundedText(value.conversationId, MAX_CONVERSATION_ID_LENGTH) ||
|
|
166
|
+
!isBoundedText(value.output, MAX_OUTPUT_LENGTH)
|
|
167
|
+
) {
|
|
168
|
+
throw adapterError();
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
conversationId: value.conversationId,
|
|
172
|
+
output: value.output,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseEventBlock(block) {
|
|
177
|
+
const dataLines = [];
|
|
178
|
+
let event;
|
|
179
|
+
for (const line of block.split(/\r?\n/u)) {
|
|
180
|
+
if (line.startsWith("event:")) {
|
|
181
|
+
event = line.slice(6).trim();
|
|
182
|
+
} else if (line.startsWith("data:")) {
|
|
183
|
+
dataLines.push(line.slice(5).trimStart());
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!event || dataLines.length === 0) {
|
|
187
|
+
throw adapterError();
|
|
188
|
+
}
|
|
189
|
+
let data;
|
|
190
|
+
try {
|
|
191
|
+
data = JSON.parse(dataLines.join("\n"));
|
|
192
|
+
} catch {
|
|
193
|
+
throw adapterError();
|
|
194
|
+
}
|
|
195
|
+
if (!isPlainObject(data)) {
|
|
196
|
+
throw adapterError();
|
|
197
|
+
}
|
|
198
|
+
return { event, data };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function consumeAdapterStream(response, onEvent) {
|
|
202
|
+
if (
|
|
203
|
+
!/^text\/event-stream(?:\s*;|$)/iu.test(
|
|
204
|
+
response.headers?.get?.("content-type") ?? "",
|
|
205
|
+
) ||
|
|
206
|
+
response.headers?.get?.("x-relmio-stream") !== "v1"
|
|
207
|
+
) {
|
|
208
|
+
throw adapterError();
|
|
209
|
+
}
|
|
210
|
+
const reader = response.body?.getReader?.();
|
|
211
|
+
if (!reader) {
|
|
212
|
+
throw adapterError();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const decoder = new TextDecoder();
|
|
216
|
+
let buffer = "";
|
|
217
|
+
let bytes = 0;
|
|
218
|
+
let conversationId;
|
|
219
|
+
let failed = false;
|
|
220
|
+
let output = "";
|
|
221
|
+
let terminal = false;
|
|
222
|
+
const processBlock = (block) => {
|
|
223
|
+
if (!block.trim() || block.trimStart().startsWith(":")) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const { event, data } = parseEventBlock(block);
|
|
227
|
+
if (event === "start") {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (event === "progress") {
|
|
231
|
+
onEvent("progress", { phase: "working" });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (event === "delta") {
|
|
235
|
+
if (
|
|
236
|
+
typeof data.text !== "string" ||
|
|
237
|
+
data.text.length === 0 ||
|
|
238
|
+
data.text.includes("\0") ||
|
|
239
|
+
output.length + data.text.length > MAX_OUTPUT_LENGTH
|
|
240
|
+
) {
|
|
241
|
+
throw adapterError();
|
|
242
|
+
}
|
|
243
|
+
output += data.text;
|
|
244
|
+
onEvent("delta", { text: data.text });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (event === "error") {
|
|
248
|
+
failed = true;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (event === "terminal") {
|
|
252
|
+
if (terminal || !["completed", "failed"].includes(data.outcome)) {
|
|
253
|
+
throw adapterError();
|
|
254
|
+
}
|
|
255
|
+
terminal = true;
|
|
256
|
+
failed ||= data.outcome !== "completed";
|
|
257
|
+
if (!failed) {
|
|
258
|
+
if (!isBoundedText(data.conversationId, MAX_CONVERSATION_ID_LENGTH)) {
|
|
259
|
+
throw adapterError();
|
|
260
|
+
}
|
|
261
|
+
conversationId = data.conversationId;
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
throw adapterError();
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
for (;;) {
|
|
270
|
+
const { done, value } = await reader.read();
|
|
271
|
+
if (done) break;
|
|
272
|
+
bytes += value.byteLength;
|
|
273
|
+
if (bytes > MAX_RESPONSE_BYTES) {
|
|
274
|
+
throw adapterError();
|
|
275
|
+
}
|
|
276
|
+
buffer += decoder.decode(value, { stream: true });
|
|
277
|
+
const blocks = buffer.split(/\r?\n\r?\n/u);
|
|
278
|
+
buffer = blocks.pop() ?? "";
|
|
279
|
+
for (const block of blocks) processBlock(block);
|
|
280
|
+
}
|
|
281
|
+
buffer += decoder.decode();
|
|
282
|
+
if (buffer.trim()) processBlock(buffer);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
try {
|
|
285
|
+
await reader.cancel();
|
|
286
|
+
} catch {
|
|
287
|
+
// The adapter stream may already be closed after a malformed terminal.
|
|
288
|
+
}
|
|
289
|
+
throw error;
|
|
290
|
+
} finally {
|
|
291
|
+
reader.releaseLock?.();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (failed || !terminal || !conversationId || output.length === 0) {
|
|
295
|
+
throw adapterError();
|
|
296
|
+
}
|
|
297
|
+
return { conversationId, output };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function isTimeout(error) {
|
|
301
|
+
return error?.name === "AbortError" || error?.name === "TimeoutError";
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function createLocalChatTestService({
|
|
305
|
+
fetchImpl = fetch,
|
|
306
|
+
now = () => Date.now(),
|
|
307
|
+
keyTtlMs = KEY_TTL_MS,
|
|
308
|
+
maxSessions = MAX_SESSIONS,
|
|
309
|
+
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
|
310
|
+
} = {}) {
|
|
311
|
+
const sessions = new Map();
|
|
312
|
+
let pendingKeyIssuances = 0;
|
|
313
|
+
|
|
314
|
+
function expireSession(keyId, session) {
|
|
315
|
+
if (sessions.get(keyId) !== session) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
clearTimeout(session.expiryTimer);
|
|
319
|
+
session.abortController?.abort();
|
|
320
|
+
session.privateKey = undefined;
|
|
321
|
+
sessions.delete(keyId);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function discardExpiredSessions() {
|
|
325
|
+
const currentTime = now();
|
|
326
|
+
for (const [keyId, session] of sessions) {
|
|
327
|
+
if (session.expiresAt <= currentTime) {
|
|
328
|
+
expireSession(keyId, session);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function resetAllSessions() {
|
|
334
|
+
for (const [keyId, session] of sessions) {
|
|
335
|
+
expireSession(keyId, session);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function getLiveSession(keyId) {
|
|
340
|
+
discardExpiredSessions();
|
|
341
|
+
const session = sessions.get(keyId);
|
|
342
|
+
if (!session || session.expiresAt <= now()) {
|
|
343
|
+
if (session) {
|
|
344
|
+
expireSession(keyId, session);
|
|
345
|
+
}
|
|
346
|
+
throw expiredKeyError();
|
|
347
|
+
}
|
|
348
|
+
return session;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
async issueKey() {
|
|
353
|
+
discardExpiredSessions();
|
|
354
|
+
if (sessions.size + pendingKeyIssuances >= maxSessions) {
|
|
355
|
+
throw requestError(
|
|
356
|
+
"Too many open tester sessions. Forget one or wait for it to expire.",
|
|
357
|
+
429,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
pendingKeyIssuances += 1;
|
|
361
|
+
try {
|
|
362
|
+
const { publicKey, privateKey } = await generateKeyPair("rsa", {
|
|
363
|
+
modulusLength: 2_048,
|
|
364
|
+
publicExponent: 0x10001,
|
|
365
|
+
});
|
|
366
|
+
const keyId = randomUUID();
|
|
367
|
+
const expiresAt = now() + keyTtlMs;
|
|
368
|
+
const session = {
|
|
369
|
+
abortController: null,
|
|
370
|
+
expiresAt,
|
|
371
|
+
expiryTimer: null,
|
|
372
|
+
inFlight: false,
|
|
373
|
+
privateKey,
|
|
374
|
+
};
|
|
375
|
+
sessions.set(keyId, session);
|
|
376
|
+
session.expiryTimer = setTimeout(
|
|
377
|
+
() => expireSession(keyId, session),
|
|
378
|
+
keyTtlMs,
|
|
379
|
+
);
|
|
380
|
+
session.expiryTimer.unref?.();
|
|
381
|
+
return {
|
|
382
|
+
keyId,
|
|
383
|
+
publicKeyJwk: publicKey.export({ format: "jwk" }),
|
|
384
|
+
algorithm: "RSA-OAEP-256",
|
|
385
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
386
|
+
};
|
|
387
|
+
} finally {
|
|
388
|
+
pendingKeyIssuances -= 1;
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
async message(untrustedRequest, options = {}) {
|
|
393
|
+
const request = validateMessageRequest(untrustedRequest);
|
|
394
|
+
const onEvent =
|
|
395
|
+
typeof options.onEvent === "function" ? options.onEvent : null;
|
|
396
|
+
const externalSignal =
|
|
397
|
+
options.signal instanceof AbortSignal ? options.signal : null;
|
|
398
|
+
const session = getLiveSession(request.keyId);
|
|
399
|
+
if (session.inFlight) {
|
|
400
|
+
throw requestError("Wait for the current test message to finish.", 409);
|
|
401
|
+
}
|
|
402
|
+
session.inFlight = true;
|
|
403
|
+
let encryptedCredential;
|
|
404
|
+
let decryptedCredential;
|
|
405
|
+
let authorization;
|
|
406
|
+
let abortFromCaller;
|
|
407
|
+
let timeout;
|
|
408
|
+
try {
|
|
409
|
+
encryptedCredential = decodeCiphertext(request.encryptedCredential);
|
|
410
|
+
try {
|
|
411
|
+
decryptedCredential = privateDecrypt(
|
|
412
|
+
{
|
|
413
|
+
key: session.privateKey,
|
|
414
|
+
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
|
415
|
+
oaepHash: "sha256",
|
|
416
|
+
},
|
|
417
|
+
encryptedCredential,
|
|
418
|
+
);
|
|
419
|
+
} catch {
|
|
420
|
+
throw requestError("Secure the client credential again before testing.");
|
|
421
|
+
}
|
|
422
|
+
if (
|
|
423
|
+
decryptedCredential.length === 0 ||
|
|
424
|
+
decryptedCredential.length > 512 ||
|
|
425
|
+
/[\r\n\u0000]/u.test(decryptedCredential.toString("utf8"))
|
|
426
|
+
) {
|
|
427
|
+
throw requestError("Secure the client credential again before testing.");
|
|
428
|
+
}
|
|
429
|
+
authorization = `Bearer ${decryptedCredential.toString("utf8")}`;
|
|
430
|
+
|
|
431
|
+
let response;
|
|
432
|
+
try {
|
|
433
|
+
session.abortController = new AbortController();
|
|
434
|
+
abortFromCaller = () => session.abortController?.abort();
|
|
435
|
+
if (externalSignal?.aborted) {
|
|
436
|
+
abortFromCaller();
|
|
437
|
+
} else {
|
|
438
|
+
externalSignal?.addEventListener("abort", abortFromCaller, {
|
|
439
|
+
once: true,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
timeout = setTimeout(
|
|
443
|
+
() => session.abortController?.abort(),
|
|
444
|
+
requestTimeoutMs,
|
|
445
|
+
);
|
|
446
|
+
response = await fetchImpl(`${request.endpointBaseUrl}/chat`, {
|
|
447
|
+
method: "POST",
|
|
448
|
+
headers: {
|
|
449
|
+
Accept: onEvent ? "text/event-stream" : "application/json",
|
|
450
|
+
Authorization: authorization,
|
|
451
|
+
"Content-Type": "application/json",
|
|
452
|
+
},
|
|
453
|
+
body: JSON.stringify({
|
|
454
|
+
input: request.input,
|
|
455
|
+
...(request.conversationId !== undefined
|
|
456
|
+
? { conversationId: request.conversationId }
|
|
457
|
+
: {}),
|
|
458
|
+
}),
|
|
459
|
+
redirect: "error",
|
|
460
|
+
signal: session.abortController.signal,
|
|
461
|
+
});
|
|
462
|
+
} catch (error) {
|
|
463
|
+
throw adapterError(isTimeout(error) ? 504 : 502);
|
|
464
|
+
}
|
|
465
|
+
if (!response?.ok || response.status < 200 || response.status >= 300) {
|
|
466
|
+
throw adapterError();
|
|
467
|
+
}
|
|
468
|
+
return onEvent
|
|
469
|
+
? await consumeAdapterStream(response, onEvent)
|
|
470
|
+
: parseAdapterResponse(await readBoundedResponse(response));
|
|
471
|
+
} finally {
|
|
472
|
+
clearTimeout(timeout);
|
|
473
|
+
externalSignal?.removeEventListener("abort", abortFromCaller);
|
|
474
|
+
authorization = undefined;
|
|
475
|
+
encryptedCredential?.fill(0);
|
|
476
|
+
decryptedCredential?.fill(0);
|
|
477
|
+
session.abortController = null;
|
|
478
|
+
session.inFlight = false;
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
async reset(request) {
|
|
483
|
+
if (!isPlainObject(request) || !isBoundedText(request.keyId, 128)) {
|
|
484
|
+
throw requestError("Choose a valid tester session to forget.");
|
|
485
|
+
}
|
|
486
|
+
const session = sessions.get(request.keyId);
|
|
487
|
+
if (session) {
|
|
488
|
+
expireSession(request.keyId, session);
|
|
489
|
+
}
|
|
490
|
+
return { forgotten: true };
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
resetAll() {
|
|
494
|
+
resetAllSessions();
|
|
495
|
+
},
|
|
496
|
+
|
|
497
|
+
dispose() {
|
|
498
|
+
resetAllSessions();
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
}
|
package/src/ui/local.css
CHANGED
|
@@ -6,6 +6,29 @@
|
|
|
6
6
|
scroll-behavior: smooth;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
.local-wizard .local-eyebrow {
|
|
10
|
+
margin: 0 0 0.4rem;
|
|
11
|
+
color: var(--accent-deep);
|
|
12
|
+
font: 800 0.6875rem/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
13
|
+
letter-spacing: 0.12em;
|
|
14
|
+
text-transform: uppercase;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.local-wizard .rail {
|
|
18
|
+
padding: 1rem;
|
|
19
|
+
border: 1px solid var(--border);
|
|
20
|
+
border-radius: var(--radius);
|
|
21
|
+
background: color-mix(in srgb, var(--surface) 78%, transparent);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.local-wizard .steps li {
|
|
25
|
+
min-height: 2.5rem;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.local-wizard .steps li[aria-current="step"] em {
|
|
29
|
+
color: var(--text);
|
|
30
|
+
}
|
|
31
|
+
|
|
9
32
|
.local-wizard .status-row {
|
|
10
33
|
margin-bottom: 1rem;
|
|
11
34
|
}
|
|
@@ -365,6 +388,165 @@
|
|
|
365
388
|
font-weight: 700;
|
|
366
389
|
}
|
|
367
390
|
|
|
391
|
+
.chat-tester {
|
|
392
|
+
display: grid;
|
|
393
|
+
gap: 1rem;
|
|
394
|
+
margin-top: 1.25rem;
|
|
395
|
+
padding: clamp(1rem, 2vw, 1.5rem);
|
|
396
|
+
border: 1px solid var(--accent-border);
|
|
397
|
+
border-radius: var(--radius);
|
|
398
|
+
background:
|
|
399
|
+
linear-gradient(color-mix(in srgb, var(--accent) 5%, transparent) 1px, transparent 1px),
|
|
400
|
+
linear-gradient(90deg, color-mix(in srgb, var(--accent) 5%, transparent) 1px, transparent 1px),
|
|
401
|
+
var(--accent-soft);
|
|
402
|
+
background-size: 28px 28px;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.chat-tester-heading {
|
|
406
|
+
display: flex;
|
|
407
|
+
align-items: flex-start;
|
|
408
|
+
justify-content: space-between;
|
|
409
|
+
gap: 1rem;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.chat-tester-heading h3 {
|
|
413
|
+
margin: 0.2rem 0 0;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.chat-tester-heading p:not(.section-label) {
|
|
417
|
+
margin: 0.45rem 0 0;
|
|
418
|
+
color: var(--text-muted);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.chat-tester-heading .section-label {
|
|
422
|
+
margin: 0;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.chat-tester-route {
|
|
426
|
+
display: grid;
|
|
427
|
+
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
|
|
428
|
+
align-items: center;
|
|
429
|
+
gap: 0.625rem;
|
|
430
|
+
padding: 0.75rem;
|
|
431
|
+
border: 1px solid var(--accent-border);
|
|
432
|
+
border-radius: var(--radius-sm);
|
|
433
|
+
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.chat-tester-route > span {
|
|
437
|
+
display: grid;
|
|
438
|
+
gap: 0.2rem;
|
|
439
|
+
min-width: 0;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.chat-tester-route strong,
|
|
443
|
+
.chat-tester-route small {
|
|
444
|
+
overflow-wrap: anywhere;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.chat-tester-route strong {
|
|
448
|
+
font-size: 0.8125rem;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.chat-tester-route small {
|
|
452
|
+
color: var(--text-muted);
|
|
453
|
+
font: 0.6875rem/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.chat-tester-route i {
|
|
457
|
+
color: var(--accent-deep);
|
|
458
|
+
font-style: normal;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
.chat-tester-form fieldset {
|
|
462
|
+
display: grid;
|
|
463
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
464
|
+
gap: 0.875rem;
|
|
465
|
+
margin: 0;
|
|
466
|
+
padding: 0;
|
|
467
|
+
border: 0;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
.chat-tester-form legend {
|
|
471
|
+
grid-column: 1 / -1;
|
|
472
|
+
width: 100%;
|
|
473
|
+
margin-bottom: 0.5rem;
|
|
474
|
+
font-weight: 800;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.chat-tester-form .button,
|
|
478
|
+
.chat-tester-message-form .button {
|
|
479
|
+
margin-top: 0.875rem;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.chat-tester-status,
|
|
483
|
+
.chat-tester-error {
|
|
484
|
+
margin: 0;
|
|
485
|
+
padding: 0.75rem 0.875rem;
|
|
486
|
+
border-radius: var(--radius-sm);
|
|
487
|
+
font-size: 0.875rem;
|
|
488
|
+
overflow-wrap: anywhere;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.chat-tester-status {
|
|
492
|
+
border: 1px solid var(--accent-border);
|
|
493
|
+
background: var(--surface);
|
|
494
|
+
color: var(--text-muted);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
.chat-tester-error {
|
|
498
|
+
border: 1px solid var(--warning-border);
|
|
499
|
+
background: var(--warning-soft);
|
|
500
|
+
color: var(--warning);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.chat-tester-transcript {
|
|
504
|
+
display: grid;
|
|
505
|
+
gap: 0.75rem;
|
|
506
|
+
max-height: 28rem;
|
|
507
|
+
margin: 0;
|
|
508
|
+
padding: 0;
|
|
509
|
+
overflow: auto;
|
|
510
|
+
list-style: none;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
.chat-tester-turn {
|
|
514
|
+
max-width: min(100%, 46rem);
|
|
515
|
+
padding: 0.75rem 0.875rem;
|
|
516
|
+
border: 1px solid var(--border-strong);
|
|
517
|
+
border-radius: var(--radius-sm);
|
|
518
|
+
background: var(--surface);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
.chat-tester-turn-assistant {
|
|
522
|
+
border-color: var(--accent-border);
|
|
523
|
+
background: var(--accent-soft);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.chat-tester-turn-incomplete {
|
|
527
|
+
border-color: var(--warning-border);
|
|
528
|
+
border-style: dashed;
|
|
529
|
+
background: var(--warning-soft);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.chat-tester-turn-incomplete strong {
|
|
533
|
+
color: var(--warning);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
.chat-tester-turn strong {
|
|
537
|
+
display: block;
|
|
538
|
+
margin-bottom: 0.25rem;
|
|
539
|
+
font-size: 0.75rem;
|
|
540
|
+
letter-spacing: 0.02em;
|
|
541
|
+
text-transform: uppercase;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.chat-tester-turn p {
|
|
545
|
+
margin: 0;
|
|
546
|
+
overflow-wrap: anywhere;
|
|
547
|
+
white-space: pre-wrap;
|
|
548
|
+
}
|
|
549
|
+
|
|
368
550
|
.local-wizard a.button {
|
|
369
551
|
text-decoration: none;
|
|
370
552
|
}
|
|
@@ -379,10 +561,23 @@
|
|
|
379
561
|
@media (max-width: 48rem) {
|
|
380
562
|
.target-picker,
|
|
381
563
|
.local-fields,
|
|
382
|
-
.local-review-grid
|
|
564
|
+
.local-review-grid,
|
|
565
|
+
.chat-tester-form fieldset {
|
|
383
566
|
grid-template-columns: minmax(0, 1fr);
|
|
384
567
|
}
|
|
385
568
|
|
|
569
|
+
.chat-tester-heading {
|
|
570
|
+
flex-direction: column;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
.chat-tester-route {
|
|
574
|
+
grid-template-columns: minmax(0, 1fr);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.chat-tester-route i {
|
|
578
|
+
transform: rotate(90deg);
|
|
579
|
+
}
|
|
580
|
+
|
|
386
581
|
.review-details div {
|
|
387
582
|
grid-template-columns: minmax(0, 1fr);
|
|
388
583
|
gap: 0.2rem;
|