@opennodes/registry 0.1.4 → 0.1.6
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/bin.js +3 -0
- package/package.json +1 -1
- package/src/namespace.js +39 -0
- package/src/server.js +41 -5
- package/src/web/index.html +106 -20
package/bin.js
CHANGED
|
@@ -10,6 +10,9 @@ const registry = await startRegistry({
|
|
|
10
10
|
// Persistent signing identity: explicit path, or derived from a file db; pg deployments set ONP_REGISTRY_KEY.
|
|
11
11
|
keyPath: process.env.ONP_REGISTRY_KEY ?? (persistent ? `${dbPath}.key.pem` : (dbPath.startsWith('postgres') ? 'onp-registry-key.pem' : null)),
|
|
12
12
|
allowPrivateTargets: process.env.ONP_ALLOW_PRIVATE === '1', // dev only: permit localhost nodes
|
|
13
|
+
registerPerHour: Number(process.env.ONP_REGISTER_PER_HOUR ?? 30), // per client address
|
|
14
|
+
admitCooldownMs: Number(process.env.ONP_ADMIT_COOLDOWN_MS ?? 30 * 60_000), // Stage B per node
|
|
15
|
+
trustProxy: process.env.ONP_TRUST_PROXY === '1', // behind Caddy / a CDN
|
|
13
16
|
adminToken: process.env.ONP_ADMIN_TOKEN ?? null,
|
|
14
17
|
stageC: process.env.ONP_STAGE_C === '0' ? null : {
|
|
15
18
|
livenessIntervalMs: Number(process.env.ONP_LIVENESS_MS ?? 300_000),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennodes/registry",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "OpenNodes (ONP) reference registry: node registration with proof-of-resource qualification (Stage A/B/C), transparent ranking, enforceable estimates, catalog importers (OpenRouter, Hugging Face, models.dev, Ollama), MCP surface, and the web app (catalog, chat, advisor, Node Builder)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/namespace.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Namespace binding (ONP-3 §4): a node id is a reverse-DNS name, and only a host that
|
|
2
|
+
// controls that DNS name may register it. Without this an HTTP challenge proves control of
|
|
3
|
+
// *some* server, not of the namespace being claimed.
|
|
4
|
+
|
|
5
|
+
/** org.example.ai.box -> box.ai.example.org */
|
|
6
|
+
export const domainForNodeId = (nodeId) => String(nodeId).split('.').reverse().join('.').toLowerCase();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* True when `host` may serve the card and HTTP challenge for `nodeId`: the id's domain is the
|
|
10
|
+
* host itself or a name beneath it (the owner of example.org controls org.example.*).
|
|
11
|
+
* A *child* host never controls its parent's namespace (alice.github.io cannot claim io.github).
|
|
12
|
+
*/
|
|
13
|
+
export function hostControlsNamespace(host, nodeId) {
|
|
14
|
+
const h = String(host).toLowerCase().replace(/\.$/, '');
|
|
15
|
+
const domain = domainForNodeId(nodeId);
|
|
16
|
+
if (!h.includes('.')) return false; // a bare TLD / single label controls nothing
|
|
17
|
+
return domain === h || domain.endsWith('.' + h);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Loopback / private literal hosts: only acceptable in dev mode (allowPrivateTargets). */
|
|
21
|
+
export function isPrivateHost(host) {
|
|
22
|
+
const h = String(host).toLowerCase().replace(/^\[|\]$/g, '');
|
|
23
|
+
return h === 'localhost' || h === '::1' || /^127\./.test(h) || /^10\./.test(h)
|
|
24
|
+
|| /^192\.168\./.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Fixed-window in-memory rate limiter keyed by client address. limit <= 0 disables it. */
|
|
28
|
+
export function makeRateLimiter(limit, windowMs = 3_600_000) {
|
|
29
|
+
const hits = new Map();
|
|
30
|
+
return (key) => {
|
|
31
|
+
if (!(limit > 0)) return true;
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const rec = hits.get(key);
|
|
34
|
+
if (!rec || now - rec.start > windowMs) { hits.set(key, { start: now, n: 1 }); return true; }
|
|
35
|
+
rec.n += 1;
|
|
36
|
+
if (hits.size > 10_000) for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k);
|
|
37
|
+
return rec.n <= limit;
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/server.js
CHANGED
|
@@ -17,6 +17,7 @@ import { startReimport } from './reimport.js';
|
|
|
17
17
|
import { makeSafeFetch } from './safefetch.js';
|
|
18
18
|
import { searchOfferings, searchOfferingsPaged, collectOfferings, RANK_COMPONENTS } from './search.js';
|
|
19
19
|
import { mcpRoutes } from './mcp.js';
|
|
20
|
+
import { hostControlsNamespace, isPrivateHost, makeRateLimiter } from './namespace.js';
|
|
20
21
|
import { exportRoutes } from './export.js';
|
|
21
22
|
import {
|
|
22
23
|
importModelsDev, importHuggingFace, importOpenRouter,
|
|
@@ -33,6 +34,10 @@ export async function startRegistry({
|
|
|
33
34
|
reimport = null, // { intervalMs, sources } to keep imported catalogs fresh
|
|
34
35
|
adminToken = null, // bearer for admin routes; without it, loopback-only
|
|
35
36
|
keyPath = null, // PEM file for a persistent signing identity; null = ephemeral
|
|
37
|
+
enforceNamespace = null, // null = enforce except for private hosts in dev mode; true/false to force
|
|
38
|
+
registerPerHour = 0, // per-client cap on register/verify calls (0 = unlimited; set in production)
|
|
39
|
+
admitCooldownMs = 0, // minimum gap between Stage B runs per node (0 = none; set in production)
|
|
40
|
+
trustProxy = false, // read the client address from X-Forwarded-For (behind Caddy / a CDN)
|
|
36
41
|
} = {}) {
|
|
37
42
|
const store = await createStore(dbPath); // sqlite path/:memory: or postgres:// URL
|
|
38
43
|
const safeFetch = makeSafeFetch({ allowPrivate: allowPrivateTargets });
|
|
@@ -76,10 +81,23 @@ export async function startRegistry({
|
|
|
76
81
|
/** node.id is reverse-DNS: org.opennodes.fixture -> fixture.opennodes.org */
|
|
77
82
|
const domainForNodeId = (nodeId) => nodeId.split('.').reverse().join('.');
|
|
78
83
|
|
|
84
|
+
/** May `originUrl`'s host register `nodeId`? (ONP-3 §4: the host must control the id's DNS namespace.) */
|
|
85
|
+
const namespaceOk = (originUrl, nodeId) => {
|
|
86
|
+
const host = new URL(originUrl).hostname;
|
|
87
|
+
if (enforceNamespace === false) return true;
|
|
88
|
+
if (enforceNamespace !== true && allowPrivateTargets && isPrivateHost(host)) return true; // dev / e2e on loopback
|
|
89
|
+
return hostControlsNamespace(host, nodeId);
|
|
90
|
+
};
|
|
91
|
+
const registerLimiter = makeRateLimiter(registerPerHour);
|
|
92
|
+
const clientOf = (req) => (trustProxy && req.headers['x-forwarded-for']?.split(',')[0].trim()) || req.socket.remoteAddress || 'unknown';
|
|
93
|
+
const isAdmin = (req) => Boolean(adminToken) && req.headers.authorization === `Bearer ${adminToken}`;
|
|
94
|
+
|
|
79
95
|
async function challengeSatisfied(node, challenge) {
|
|
80
|
-
try { // HTTP method
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
try { // HTTP method — only from a host that controls the id's namespace
|
|
97
|
+
if (namespaceOk(node.origin, node.id)) {
|
|
98
|
+
const resp = await safeFetch(`${node.origin}/.well-known/onp-challenge/${challenge.token}`, {}, 10_000);
|
|
99
|
+
if (resp.ok) return { ok: true, method: 'http' };
|
|
100
|
+
}
|
|
83
101
|
} catch { /* fall through to DNS */ }
|
|
84
102
|
try { // DNS TXT method: _onp-challenge.{domain} TXT "{token}"
|
|
85
103
|
const records = await dnsResolveTxt(`_onp-challenge.${domainForNodeId(node.id)}`);
|
|
@@ -118,6 +136,7 @@ export async function startRegistry({
|
|
|
118
136
|
|
|
119
137
|
const app = createApp([
|
|
120
138
|
['POST', '/v0/nodes', async (req, res) => {
|
|
139
|
+
if (!registerLimiter(clientOf(req))) return problem(res, 429, 'rate-limited', 'too many registration attempts from this address; try again later');
|
|
121
140
|
const { card_url } = await readJson(req);
|
|
122
141
|
if (!card_url) return problem(res, 400, 'missing-card-url', 'body must be {"card_url": "..."}');
|
|
123
142
|
let card;
|
|
@@ -127,13 +146,22 @@ export async function startRegistry({
|
|
|
127
146
|
|
|
128
147
|
const nodeId = card.node.id;
|
|
129
148
|
const origin = new URL(card_url).origin;
|
|
149
|
+
// The card must be served by a host that controls the claimed id (reverse-DNS). Otherwise anyone
|
|
150
|
+
// could host a card naming someone else's node and pass the HTTP challenge on their own server.
|
|
151
|
+
if (!namespaceOk(origin, nodeId)) {
|
|
152
|
+
return problem(res, 403, 'namespace-mismatch',
|
|
153
|
+
`node id ${nodeId} maps to ${domainForNodeId(nodeId)}; its card must be served from that host or a parent domain, not ${new URL(origin).hostname}`);
|
|
154
|
+
}
|
|
155
|
+
const existing = await store.getNode(nodeId);
|
|
156
|
+
const listed = existing && !['challenged', 'submitted'].includes(existing.state) && existing.source !== 'import';
|
|
130
157
|
await store.upsertNode(nodeId, origin, card_url, 'challenged');
|
|
131
|
-
|
|
158
|
+
// Re-registration of a listed node issues a fresh challenge but never delists it: state moves at /verify.
|
|
159
|
+
if (!listed) await store.setState(nodeId, 'challenged', 'registration received');
|
|
132
160
|
const token = randomUUID();
|
|
133
161
|
await store.setChallenge(nodeId, token);
|
|
134
162
|
sendJson(res, 202, {
|
|
135
163
|
node_id: nodeId,
|
|
136
|
-
state: 'challenged',
|
|
164
|
+
state: listed ? existing.state : 'challenged',
|
|
137
165
|
challenge: {
|
|
138
166
|
methods: {
|
|
139
167
|
http: `${origin}/.well-known/onp-challenge/${token}`,
|
|
@@ -146,6 +174,7 @@ export async function startRegistry({
|
|
|
146
174
|
}],
|
|
147
175
|
|
|
148
176
|
['POST', '/v0/nodes/:id/verify', async (req, res, { params }) => {
|
|
177
|
+
if (!registerLimiter(clientOf(req))) return problem(res, 429, 'rate-limited', 'too many verification attempts from this address; try again later');
|
|
149
178
|
const node = await store.getNode(params.id);
|
|
150
179
|
if (!node) return problem(res, 404, 'unknown-node', params.id);
|
|
151
180
|
const challenge = await store.getChallenge(params.id);
|
|
@@ -188,6 +217,13 @@ export async function startRegistry({
|
|
|
188
217
|
if (node.state !== 'community' && node.state !== 'verified') {
|
|
189
218
|
return problem(res, 409, 'wrong-state', `Stage B requires community tier, node is ${node.state}`);
|
|
190
219
|
}
|
|
220
|
+
if (admitCooldownMs > 0 && !isAdmin(req)) {
|
|
221
|
+
const lastB = (await store.probesFor(params.id)).find((p) => p.stage === 'B');
|
|
222
|
+
const age = lastB ? Date.now() - Date.parse(lastB.at) : Infinity;
|
|
223
|
+
if (age < admitCooldownMs) {
|
|
224
|
+
return problem(res, 429, 'admission-cooldown', `Stage B ran ${Math.round(age / 60000)} min ago; next run allowed in ${Math.ceil((admitCooldownMs - age) / 60000)} min`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
191
227
|
await refreshCard(params.id);
|
|
192
228
|
const card = await store.latestCard(params.id);
|
|
193
229
|
const stageB = await runStageB(store, params.id, card, { safeFetch, capacity: stageBCapacity });
|
package/src/web/index.html
CHANGED
|
@@ -396,8 +396,13 @@
|
|
|
396
396
|
<button class="btn sec" id="sc-load" style="padding:0.35rem 0.7rem; font-size:0.74rem">Load</button>
|
|
397
397
|
<button class="btn sec" id="sc-del" style="padding:0.35rem 0.7rem; font-size:0.74rem" title="delete">✕</button>
|
|
398
398
|
</div>
|
|
399
|
-
<
|
|
400
|
-
|
|
399
|
+
<div style="display:flex; gap:0.4rem; margin-top:0.4rem; flex-wrap:wrap">
|
|
400
|
+
<button class="btn sec" id="sc-export" style="padding:0.35rem 0.7rem; font-size:0.74rem">Export JSON</button>
|
|
401
|
+
<button class="btn sec" id="sc-import" style="padding:0.35rem 0.7rem; font-size:0.74rem">Import JSON</button>
|
|
402
|
+
<button class="btn sec" id="sc-share" style="padding:0.35rem 0.7rem; font-size:0.74rem">Copy share link</button>
|
|
403
|
+
<input type="file" id="sc-file" accept=".json,application/json" hidden>
|
|
404
|
+
</div>
|
|
405
|
+
<p class="prov" id="sc-note" style="margin:0.35rem 0 0">Saved in your browser — no account. Export/import use the portable ONP-6 §5 format; a share link carries the whole scenario in the URL (never your API keys).</p>
|
|
401
406
|
</details>
|
|
402
407
|
<label class="f">API key <span style="font-weight:400">(per host, kept in your browser)</span></label>
|
|
403
408
|
<input class="wide mono" id="ch-key" type="password" placeholder="optional" style="font-size:0.76rem">
|
|
@@ -1002,7 +1007,7 @@ async function loadChatTargets() {
|
|
|
1002
1007
|
+ (local.length ? `<optgroup label="On your machine — needs a local Ollama with this model pulled">${local.map((o, i) => opt(o, ready.length + i)).join('')}</optgroup>` : '')
|
|
1003
1008
|
+ (imports.length ? `<optgroup label="Imported catalogs — your own API key required">${imports.map((o, i) => opt(o, ready.length + local.length + i)).join('')}</optgroup>` : '')
|
|
1004
1009
|
+ '<option value="custom">custom endpoint (local Ollama / any OpenAI server)</option>';
|
|
1005
|
-
if (!chat.roster.length && ready.length && !sessionStorage.getItem('onp-advisor-handoff')) {
|
|
1010
|
+
if (!chat.roster.length && ready.length && !sessionStorage.getItem('onp-advisor-handoff') && !/[?&]s=/.test(location.hash)) {
|
|
1006
1011
|
chat.roster.push({ offering: ready[0] }); // a newcomer can just type
|
|
1007
1012
|
renderRoster();
|
|
1008
1013
|
const log = $('#ch-log');
|
|
@@ -1011,6 +1016,7 @@ async function loadChatTargets() {
|
|
|
1011
1016
|
} catch { $('#ch-add').innerHTML = '<option value="custom">custom endpoint</option>'; }
|
|
1012
1017
|
}
|
|
1013
1018
|
await applyAdvisorHandoff();
|
|
1019
|
+
await applySharedScenario();
|
|
1014
1020
|
}
|
|
1015
1021
|
|
|
1016
1022
|
/** The catalog advisor hands its shortlist to the chat roster via sessionStorage. */
|
|
@@ -1404,38 +1410,47 @@ $('#sc-save').addEventListener('click', () => {
|
|
|
1404
1410
|
scRefreshList(); $('#sc-list').value = name;
|
|
1405
1411
|
$('#sc-note').textContent = `saved "${name}"`;
|
|
1406
1412
|
});
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
if (!sc) return;
|
|
1410
|
-
await loadChatTargets();
|
|
1413
|
+
/** Apply a scenario snapshot to the chat (roster, mode, instructions). Used by Load, Import, and share links. */
|
|
1414
|
+
async function scApply(sc, verb = 'loaded') {
|
|
1411
1415
|
chat.roster = [];
|
|
1412
1416
|
const missing = [];
|
|
1413
|
-
for (const entry of sc.roster) {
|
|
1414
|
-
if (entry.custom) chat.roster.push({ custom: entry.custom });
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
const
|
|
1418
|
-
|
|
1417
|
+
for (const entry of sc.roster ?? []) {
|
|
1418
|
+
if (entry.custom) { chat.roster.push({ custom: entry.custom }); continue; }
|
|
1419
|
+
let o = null;
|
|
1420
|
+
if (entry.pin) {
|
|
1421
|
+
const [nodeId, ...rest] = entry.pin.split('/'); const offId = rest.join('/');
|
|
1422
|
+
o = chat.offerings.find((x) => x.node_id === nodeId && x.offering_id === offId);
|
|
1423
|
+
if (!o) { try { o = await api(`/v0/offerings/${encodeURIComponent(nodeId)}/${encodeURIComponent(offId)}`); } catch { o = null; } }
|
|
1424
|
+
} else if (entry.select) { // ONP-6 late binding: resolve the requirement against this registry now
|
|
1425
|
+
try { o = (await api('/v0/offerings?limit=1&' + new URLSearchParams(entry.select))).offerings[0] ?? null; } catch { o = null; }
|
|
1419
1426
|
}
|
|
1427
|
+
if (o) chat.roster.push({ offering: o }); else missing.push(entry.pin ?? JSON.stringify(entry.select));
|
|
1420
1428
|
}
|
|
1421
|
-
|
|
1429
|
+
const MODES = ['single', 'compare', 'jury', 'pipeline', 'iterative'];
|
|
1430
|
+
$('#ch-mode').value = MODES.includes(sc.mode) ? sc.mode : 'single'; $('#ch-mode').dispatchEvent(new Event('change'));
|
|
1422
1431
|
$('#ch-rounds').value = String(sc.rounds || 2);
|
|
1423
1432
|
$('#ch-seqprompt').value = sc.instructions?.pipeline ?? $('#ch-seqprompt').value;
|
|
1424
1433
|
$('#ch-juryprompt').value = sc.instructions?.jury ?? $('#ch-juryprompt').value;
|
|
1425
1434
|
$('#ch-revprompt').value = sc.instructions?.revise ?? $('#ch-revprompt').value;
|
|
1426
1435
|
renderRoster();
|
|
1427
1436
|
if (sc.judge < chat.roster.length) $('#ch-judge').value = String(sc.judge);
|
|
1437
|
+
if (sc.name) $('#sc-name').value = sc.name;
|
|
1428
1438
|
$('#sc-note').textContent = missing.length
|
|
1429
|
-
?
|
|
1430
|
-
:
|
|
1439
|
+
? `${verb}; ${missing.length} model(s) could not be resolved here: ${missing.join(', ')}`
|
|
1440
|
+
: `${verb} "${sc.name ?? 'scenario'}" — ${chat.roster.length} model(s), mode ${$('#ch-mode').value}`;
|
|
1441
|
+
}
|
|
1442
|
+
$('#sc-load').addEventListener('click', async () => {
|
|
1443
|
+
const sc = scAll()[$('#sc-list').value];
|
|
1444
|
+
if (!sc) return;
|
|
1445
|
+
await loadChatTargets();
|
|
1446
|
+
await scApply(sc);
|
|
1431
1447
|
});
|
|
1432
1448
|
$('#sc-del').addEventListener('click', () => {
|
|
1433
1449
|
const all = scAll(); delete all[$('#sc-list').value];
|
|
1434
1450
|
localStorage.setItem(SC_STORE, JSON.stringify(all)); scRefreshList();
|
|
1435
1451
|
});
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
const s = scSnapshot($('#sc-name').value.trim() || $('#sc-list').value || 'chat-scenario');
|
|
1452
|
+
/** Snapshot -> portable ONP-6 §5 Scenario document. */
|
|
1453
|
+
function scToDoc(s) {
|
|
1439
1454
|
const roles = {};
|
|
1440
1455
|
s.roster.forEach((r, i) => { roles['m' + (i + 1)] = r.pin ? { pin: r.pin } : { custom: r.custom }; });
|
|
1441
1456
|
let steps = [];
|
|
@@ -1450,7 +1465,78 @@ $('#sc-export').addEventListener('click', () => {
|
|
|
1450
1465
|
steps.push({ id: 'verdict', role: roleIds[Math.min(s.judge, roleIds.length - 1)], merge: 'judge', instruction: s.instructions.jury });
|
|
1451
1466
|
}
|
|
1452
1467
|
}
|
|
1453
|
-
|
|
1468
|
+
return { onp_scenario: '0.1', name: s.name, roles, steps, policy: {} };
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/** ONP-6 Scenario document (or a native snapshot) -> snapshot the chat can apply. */
|
|
1472
|
+
function scFromDoc(doc) {
|
|
1473
|
+
if (!doc || typeof doc !== 'object') throw new Error('not a JSON object');
|
|
1474
|
+
if (Array.isArray(doc.roster) && doc.mode) return doc; // already a native snapshot
|
|
1475
|
+
if (!doc.onp_scenario || !doc.roles || !Array.isArray(doc.steps)) throw new Error('not an ONP-6 scenario (needs onp_scenario, roles, steps)');
|
|
1476
|
+
const roleIds = Object.keys(doc.roles);
|
|
1477
|
+
const roster = roleIds.map((id) => {
|
|
1478
|
+
const r = doc.roles[id] ?? {};
|
|
1479
|
+
if (r.custom) return { custom: r.custom };
|
|
1480
|
+
if (r.pin) return { pin: r.pin };
|
|
1481
|
+
if (r.node_id && r.offering_id) return { pin: `${r.node_id}/${r.offering_id}` };
|
|
1482
|
+
if (r.select) return { select: r.select };
|
|
1483
|
+
return { select: {} };
|
|
1484
|
+
});
|
|
1485
|
+
const fan = doc.steps.find((st) => st.mode === 'parallel');
|
|
1486
|
+
const verdict = doc.steps.find((st) => st.merge === 'judge' || st.merge?.role);
|
|
1487
|
+
let mode = 'single';
|
|
1488
|
+
if (fan) mode = fan.rounds ? 'iterative' : (verdict ? 'jury' : 'compare');
|
|
1489
|
+
else if (doc.steps.length > 1) mode = 'pipeline';
|
|
1490
|
+
const judgeRole = verdict?.role ?? verdict?.merge?.role;
|
|
1491
|
+
return {
|
|
1492
|
+
name: doc.name ?? 'imported-scenario', mode,
|
|
1493
|
+
rounds: fan?.rounds ?? 2,
|
|
1494
|
+
judge: Math.max(0, roleIds.indexOf(judgeRole)),
|
|
1495
|
+
roster,
|
|
1496
|
+
instructions: {
|
|
1497
|
+
pipeline: doc.steps.find((st) => st.instruction && !st.merge)?.instruction ?? doc.steps.find((st) => st.prompt && st.input && st.input !== '$user')?.prompt,
|
|
1498
|
+
jury: verdict?.instruction,
|
|
1499
|
+
revise: fan?.revise_instruction,
|
|
1500
|
+
},
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
const b64u = {
|
|
1504
|
+
enc: (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
|
|
1505
|
+
dec: (b) => new TextDecoder().decode(Uint8Array.from(atob(b.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0))),
|
|
1506
|
+
};
|
|
1507
|
+
const scCurrentDoc = () => scToDoc(scSnapshot($('#sc-name').value.trim() || $('#sc-list').value || 'chat-scenario'));
|
|
1508
|
+
|
|
1509
|
+
$('#sc-import').addEventListener('click', () => $('#sc-file').click());
|
|
1510
|
+
$('#sc-file').addEventListener('change', async (e) => {
|
|
1511
|
+
const file = e.target.files[0]; e.target.value = '';
|
|
1512
|
+
if (!file) return;
|
|
1513
|
+
try {
|
|
1514
|
+
const sc = scFromDoc(JSON.parse(await file.text()));
|
|
1515
|
+
await loadChatTargets();
|
|
1516
|
+
await scApply(sc, 'imported');
|
|
1517
|
+
} catch (err) { $('#sc-note').textContent = 'import failed: ' + err.message; }
|
|
1518
|
+
});
|
|
1519
|
+
$('#sc-share').addEventListener('click', async () => {
|
|
1520
|
+
if (!chat.roster.length) { $('#sc-note').textContent = 'add at least one model before sharing'; return; }
|
|
1521
|
+
const url = `${location.origin}${location.pathname}#/chat?s=${b64u.enc(JSON.stringify(scCurrentDoc()))}`;
|
|
1522
|
+
try { await navigator.clipboard.writeText(url); $('#sc-note').textContent = `share link copied (${url.length} chars) — it contains the roster, mode and instructions, never API keys`; }
|
|
1523
|
+
catch { prompt('Copy this share link:', url); }
|
|
1524
|
+
});
|
|
1525
|
+
/** A #/chat?s=… link carries a whole scenario; apply it once, then clean the address bar. */
|
|
1526
|
+
async function applySharedScenario() {
|
|
1527
|
+
const m = location.hash.match(/[?&]s=([A-Za-z0-9_-]+)/);
|
|
1528
|
+
if (!m) return;
|
|
1529
|
+
history.replaceState(null, '', location.pathname + '#/chat');
|
|
1530
|
+
try {
|
|
1531
|
+
await scApply(scFromDoc(JSON.parse(b64u.dec(m[1]))), 'opened shared scenario');
|
|
1532
|
+
const log = $('#ch-log');
|
|
1533
|
+
if (log && !chat.history.length) log.innerHTML = `<p class="sub" style="margin:0">Shared scenario <b>${esc($('#sc-name').value || 'scenario')}</b> loaded: ${chat.roster.length} model(s), mode <b>${esc($('#ch-mode').value)}</b>. Review the roster on the right, then say something.</p>`;
|
|
1534
|
+
} catch (err) { $('#sc-note').textContent = 'could not open the shared scenario: ' + err.message; }
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
$('#sc-export').addEventListener('click', () => {
|
|
1538
|
+
const doc = scCurrentDoc();
|
|
1539
|
+
const s = { name: doc.name };
|
|
1454
1540
|
const a = document.createElement('a');
|
|
1455
1541
|
a.href = URL.createObjectURL(new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' }));
|
|
1456
1542
|
a.download = s.name.replace(/[^a-z0-9._-]+/gi, '-') + '.onp-scenario.json';
|