@webmcp-auto-ui/sdk 0.2.0 → 0.4.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/README.md +9 -6
- package/package.json +7 -1
- package/src/canvas-vanilla.ts +3 -0
- package/src/index.ts +73 -14
- package/src/mcp-demo-servers.ts +67 -0
- package/src/skills/registry.ts +2 -2
- package/src/stores/canvas.svelte.ts +57 -10
- package/src/stores/canvas.ts +350 -0
- package/tests/hyperskill.test.ts +23 -22
- package/src/hyperskill/format.ts +0 -130
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ HyperSkill URL format, skills CRUD registry, and Svelte 5 canvas store.
|
|
|
4
4
|
|
|
5
5
|
## HyperSkill format
|
|
6
6
|
|
|
7
|
-
A skill is a serialised UI state
|
|
7
|
+
A skill is a serialised UI state -- a list of blocks with their data, plus metadata (MCP server, LLM, tags). It encodes to a URL parameter:
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
https://example.com/viewer?hs=base64(JSON)
|
|
@@ -12,13 +12,16 @@ https://example.com/viewer?hs=base64(JSON)
|
|
|
12
12
|
|
|
13
13
|
Skills above 6 KB are compressed with `CompressionStream` (`gz.` prefix). Each version carries a SHA-256 hash of `source_url + content` for traceability. Hashes are chainable: each version can reference the hash of the previous one.
|
|
14
14
|
|
|
15
|
+
The SDK re-exports `encode`, `decode`, `hash`, `diff`, and `getHsParam` from the [`hyperskills`](https://www.npmjs.com/package/hyperskills) NPM package:
|
|
16
|
+
|
|
15
17
|
```ts
|
|
16
|
-
import {
|
|
18
|
+
import { encode, decode, hash, diff, getHsParam } from '@webmcp-auto-ui/sdk';
|
|
17
19
|
|
|
18
|
-
const url = await
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
20
|
+
const url = await encode('https://example.com/viewer', JSON.stringify(skill));
|
|
21
|
+
const { content } = await decode(url); // or pass raw ?hs= param
|
|
22
|
+
const parsed = JSON.parse(content);
|
|
23
|
+
const h = await hash(sourceUrl, JSON.stringify(parsed.content));
|
|
24
|
+
const changed = diff(prev.content, next.content); // ['blocks', 'meta']
|
|
22
25
|
```
|
|
23
26
|
|
|
24
27
|
## Skills registry
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webmcp-auto-ui/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Skills CRUD, HyperSkill format, Svelte 5 stores",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"./canvas": {
|
|
15
15
|
"svelte": "./src/canvas.ts",
|
|
16
16
|
"import": "./src/canvas.ts"
|
|
17
|
+
},
|
|
18
|
+
"./canvas-vanilla": {
|
|
19
|
+
"import": "./src/canvas-vanilla.ts"
|
|
17
20
|
}
|
|
18
21
|
},
|
|
19
22
|
"publishConfig": {
|
|
@@ -23,6 +26,9 @@
|
|
|
23
26
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
|
24
27
|
"build": "svelte-package -i src"
|
|
25
28
|
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"hyperskills": "^0.1.0"
|
|
31
|
+
},
|
|
26
32
|
"peerDependencies": {
|
|
27
33
|
"svelte": "^5.0.0"
|
|
28
34
|
},
|
package/src/index.ts
CHANGED
|
@@ -1,19 +1,74 @@
|
|
|
1
1
|
// @webmcp-auto-ui/sdk — public API
|
|
2
2
|
|
|
3
|
-
// HyperSkill
|
|
4
|
-
export {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
3
|
+
// HyperSkill types — project-specific vocabulary for skill serialization
|
|
4
|
+
export interface HyperSkillMeta {
|
|
5
|
+
title?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
version?: string;
|
|
8
|
+
created?: string;
|
|
9
|
+
mcp?: string;
|
|
10
|
+
mcpName?: string;
|
|
11
|
+
llm?: string;
|
|
12
|
+
tags?: string[];
|
|
13
|
+
theme?: Record<string, string>;
|
|
14
|
+
hash?: string;
|
|
15
|
+
previousHash?: string;
|
|
16
|
+
chatSummary?: string;
|
|
17
|
+
provenance?: {
|
|
18
|
+
mcpServers?: string[];
|
|
19
|
+
toolsUsed?: string[];
|
|
20
|
+
toolCallCount?: number;
|
|
21
|
+
skillsReferenced?: string[];
|
|
22
|
+
llm?: string;
|
|
23
|
+
exportedAt?: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export interface HyperSkill {
|
|
27
|
+
meta: HyperSkillMeta;
|
|
28
|
+
content: unknown;
|
|
29
|
+
}
|
|
30
|
+
export interface HyperSkillVersion {
|
|
31
|
+
hash: string;
|
|
32
|
+
previousHash?: string;
|
|
33
|
+
timestamp: number;
|
|
34
|
+
skill: HyperSkill;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// HyperSkill encoding — powered by the `hyperskills` NPM package.
|
|
38
|
+
// Raw functions re-exported for direct access:
|
|
39
|
+
export { encode, decode, hash, diff, getHsParam } from 'hyperskills';
|
|
40
|
+
|
|
41
|
+
// Typed convenience wrappers — prefer these in apps:
|
|
42
|
+
import { encode, decode, hash } from 'hyperskills';
|
|
43
|
+
|
|
44
|
+
export async function encodeHyperSkill(skill: HyperSkill, sourceUrl?: string): Promise<string> {
|
|
45
|
+
const base = sourceUrl ?? (typeof window !== 'undefined' ? window.location.href.split('?')[0] : 'https://example.com');
|
|
46
|
+
const json = JSON.stringify(skill);
|
|
47
|
+
// Auto-compress with gzip when payload exceeds 6 KB to keep URLs under nginx limits
|
|
48
|
+
const compress = json.length > 6144 ? 'gz' as const : undefined;
|
|
49
|
+
return encode(base, json, compress ? { compress } : {});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function decodeHyperSkill(urlOrParam: string): Promise<HyperSkill> {
|
|
53
|
+
const { content } = await decode(urlOrParam);
|
|
54
|
+
return JSON.parse(content) as HyperSkill;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function computeHash(sourceUrl: string, content: unknown): Promise<string> {
|
|
58
|
+
return hash(sourceUrl, JSON.stringify(content));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function createVersion(skill: HyperSkill, sourceUrl: string, previousHash?: string): Promise<HyperSkillVersion> {
|
|
62
|
+
const h = await computeHash(sourceUrl, skill.content);
|
|
63
|
+
return {
|
|
64
|
+
hash: h,
|
|
65
|
+
previousHash,
|
|
66
|
+
timestamp: Date.now(),
|
|
67
|
+
skill: { ...skill, meta: { ...skill.meta, hash: h, previousHash } },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const diffSkills = diff;
|
|
17
72
|
|
|
18
73
|
// Skills registry
|
|
19
74
|
export {
|
|
@@ -29,5 +84,9 @@ export {
|
|
|
29
84
|
} from './skills/registry.js';
|
|
30
85
|
export type { Skill, SkillBlock } from './skills/registry.js';
|
|
31
86
|
|
|
87
|
+
// MCP demo servers
|
|
88
|
+
export { MCP_DEMO_SERVERS } from './mcp-demo-servers.js';
|
|
89
|
+
export type { McpDemoServer } from './mcp-demo-servers.js';
|
|
90
|
+
|
|
32
91
|
// Canvas store — browser-only (Svelte 5 runes), import directly from src:
|
|
33
92
|
// import { canvas } from '@webmcp-auto-ui/sdk/canvas'
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @webmcp-auto-ui/sdk — MCP demo servers registry
|
|
2
|
+
// Lists all MCP servers available on the production VM (demos.hyperskills.net)
|
|
3
|
+
|
|
4
|
+
export interface McpDemoServer {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
url: string;
|
|
9
|
+
tags?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* MCP demo servers available for webmcp-auto-ui demos.
|
|
14
|
+
* code4code has its own domain; others follow the pattern
|
|
15
|
+
* https://demos.hyperskills.net/<id>/mcp
|
|
16
|
+
*/
|
|
17
|
+
export const MCP_DEMO_SERVERS: McpDemoServer[] = [
|
|
18
|
+
{
|
|
19
|
+
id: 'code4code',
|
|
20
|
+
name: 'Code4Code',
|
|
21
|
+
description: 'Base de données parlementaire française — amendements, scrutins, députés, groupes politiques.',
|
|
22
|
+
url: 'https://mcp.code4code.eu/mcp',
|
|
23
|
+
tags: ['politique', 'france', 'parlement', 'open-data'],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'hackernews',
|
|
27
|
+
name: 'Hacker News',
|
|
28
|
+
description: 'Hacker News stories, commentaires et classements.',
|
|
29
|
+
url: 'https://demos.hyperskills.net/mcp-hackernews/mcp',
|
|
30
|
+
tags: ['tech', 'news', 'communauté'],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'metmuseum',
|
|
34
|
+
name: 'Met Museum',
|
|
35
|
+
description: 'Metropolitan Museum of Art — collections, œuvres, artistes.',
|
|
36
|
+
url: 'https://demos.hyperskills.net/mcp-metmuseum/mcp',
|
|
37
|
+
tags: ['art', 'musée', 'culture', 'collections'],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'openmeteo',
|
|
41
|
+
name: 'Open-Meteo',
|
|
42
|
+
description: 'Données météorologiques — prévisions, historique, géolocalisation.',
|
|
43
|
+
url: 'https://demos.hyperskills.net/mcp-openmeteo/mcp',
|
|
44
|
+
tags: ['météo', 'climat', 'prévisions', 'géo'],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'wikipedia',
|
|
48
|
+
name: 'Wikipedia',
|
|
49
|
+
description: 'Recherche et contenu Wikipedia — articles, résumés, catégories.',
|
|
50
|
+
url: 'https://demos.hyperskills.net/mcp-wikipedia/mcp',
|
|
51
|
+
tags: ['encyclopédie', 'savoir', 'recherche'],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'inaturalist',
|
|
55
|
+
name: 'iNaturalist',
|
|
56
|
+
description: 'Observations naturalistes — espèces, taxons, statistiques biodiversité.',
|
|
57
|
+
url: 'https://demos.hyperskills.net/mcp-inaturalist/mcp',
|
|
58
|
+
tags: ['nature', 'biodiversité', 'observations', 'science-participative'],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'datagouv',
|
|
62
|
+
name: 'data.gouv.fr',
|
|
63
|
+
description: 'Open data français — jeux de données publics, statistiques, référentiels.',
|
|
64
|
+
url: 'https://demos.hyperskills.net/mcp-datagouv/mcp',
|
|
65
|
+
tags: ['open-data', 'france', 'gouvernement', 'statistiques'],
|
|
66
|
+
},
|
|
67
|
+
];
|
package/src/skills/registry.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Skills registry — in-memory CRUD for
|
|
3
|
-
* Each skill
|
|
2
|
+
* Skills registry — in-memory CRUD for skills.
|
|
3
|
+
* Each skill is a set of instructions that help an agent use tools.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export interface SkillBlock {
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Manages blocks on the canvas, mode, MCP connection, chat history
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { decode } from 'hyperskills';
|
|
7
|
+
|
|
6
8
|
export type BlockType =
|
|
7
9
|
| 'stat' | 'kv' | 'list' | 'chart' | 'alert' | 'code' | 'text' | 'actions' | 'tags'
|
|
8
10
|
| 'stat-card' | 'data-table' | 'timeline' | 'profile' | 'trombinoscope' | 'json-viewer'
|
|
@@ -160,20 +162,46 @@ function createCanvas() {
|
|
|
160
162
|
return skill;
|
|
161
163
|
}
|
|
162
164
|
|
|
163
|
-
function buildHyperskillParam(): string {
|
|
165
|
+
async function buildHyperskillParam(): Promise<string> {
|
|
164
166
|
const json = JSON.stringify(buildSkillJSON());
|
|
165
|
-
|
|
167
|
+
const bytes = new TextEncoder().encode(json);
|
|
168
|
+
// Auto-compress with gzip when payload exceeds 6 KB to keep URLs under nginx limits
|
|
169
|
+
if (bytes.length > 6144) {
|
|
170
|
+
const cs = new CompressionStream('gzip');
|
|
171
|
+
const writer = cs.writable.getWriter();
|
|
172
|
+
writer.write(bytes);
|
|
173
|
+
writer.close();
|
|
174
|
+
const compressed = new Uint8Array(await new Response(cs.readable).arrayBuffer());
|
|
175
|
+
const b64 = btoa(String.fromCharCode(...compressed))
|
|
176
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
177
|
+
return 'gz.' + b64;
|
|
178
|
+
}
|
|
179
|
+
// Small payloads: plain base64url
|
|
166
180
|
return btoa(unescape(encodeURIComponent(json)))
|
|
167
181
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
168
182
|
}
|
|
169
183
|
|
|
170
|
-
function loadFromParam(param: string): boolean {
|
|
184
|
+
async function loadFromParam(param: string): Promise<boolean> {
|
|
171
185
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
186
|
+
let json: string;
|
|
187
|
+
|
|
188
|
+
if (param.startsWith('gz.')) {
|
|
189
|
+
// Compressed: gz.<base64url-encoded gzip data>
|
|
190
|
+
let b64 = param.slice(3).replace(/-/g, '+').replace(/_/g, '/');
|
|
191
|
+
while (b64.length % 4) b64 += '=';
|
|
192
|
+
const compressed = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
193
|
+
const ds = new DecompressionStream('gzip');
|
|
194
|
+
const writer = ds.writable.getWriter();
|
|
195
|
+
writer.write(compressed);
|
|
196
|
+
writer.close();
|
|
197
|
+
json = await new Response(ds.readable).text();
|
|
198
|
+
} else {
|
|
199
|
+
// Plain base64url
|
|
200
|
+
let b64 = param.replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/');
|
|
201
|
+
while (b64.length % 4) b64 += '=';
|
|
202
|
+
json = decodeURIComponent(escape(atob(b64)));
|
|
203
|
+
}
|
|
175
204
|
|
|
176
|
-
const json = decodeURIComponent(escape(atob(b64)));
|
|
177
205
|
const skill = JSON.parse(json) as {
|
|
178
206
|
mcp?: string; llm?: LLMId;
|
|
179
207
|
theme?: Record<string, string>;
|
|
@@ -191,25 +219,44 @@ function createCanvas() {
|
|
|
191
219
|
}
|
|
192
220
|
}
|
|
193
221
|
|
|
222
|
+
// ── loadFromUrl ──────────────────────────────────────────────────────────
|
|
223
|
+
async function loadFromUrl(url: string): Promise<boolean> {
|
|
224
|
+
try {
|
|
225
|
+
const { content: raw } = await decode(url);
|
|
226
|
+
const decoded = JSON.parse(raw) as { meta?: Record<string, unknown>; content?: { blocks?: { type: BlockType; data: Record<string, unknown> }[] } };
|
|
227
|
+
if (decoded.meta?.mcp) mcpUrl = decoded.meta.mcp as string;
|
|
228
|
+
if (decoded.meta?.llm) llm = decoded.meta.llm as LLMId;
|
|
229
|
+
if (decoded.meta?.theme) themeOverrides = decoded.meta.theme as Record<string, string>;
|
|
230
|
+
if (decoded.content?.blocks) blocks = decoded.content.blocks.map((b) => ({ id: uuid(), type: b.type, data: b.data }));
|
|
231
|
+
return true;
|
|
232
|
+
} catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
194
237
|
// ── Return public API ────────────────────────────────────────────────────
|
|
195
238
|
return {
|
|
196
|
-
// State getters (reactive)
|
|
239
|
+
// State getters + setters (reactive — supports bind:)
|
|
197
240
|
get blocks() { return blocks; },
|
|
198
241
|
get mode() { return mode; },
|
|
242
|
+
set mode(v: Mode) { mode = v; },
|
|
199
243
|
get llm() { return llm; },
|
|
244
|
+
set llm(v: LLMId) { llm = v; },
|
|
200
245
|
get mcpUrl() { return mcpUrl; },
|
|
246
|
+
set mcpUrl(v: string) { mcpUrl = v; },
|
|
201
247
|
get mcpConnected() { return mcpConnected; },
|
|
202
248
|
get mcpConnecting() { return mcpConnecting; },
|
|
203
249
|
get mcpName() { return mcpName; },
|
|
204
250
|
get mcpTools() { return mcpTools; },
|
|
205
251
|
get messages() { return messages; },
|
|
206
252
|
get generating() { return generating; },
|
|
253
|
+
set generating(v: boolean) { generating = v; },
|
|
207
254
|
get statusText() { return statusText; },
|
|
208
255
|
get statusColor() { return statusColor; },
|
|
209
256
|
get blockCount() { return blockCount; },
|
|
210
257
|
get isEmpty() { return isEmpty; },
|
|
211
258
|
|
|
212
|
-
// Setters
|
|
259
|
+
// Setters (kept for backward compat)
|
|
213
260
|
setMode(m: Mode) { mode = m; },
|
|
214
261
|
setLlm(l: LLMId) { llm = l; },
|
|
215
262
|
setMcpUrl(u: string) { mcpUrl = u; },
|
|
@@ -229,7 +276,7 @@ function createCanvas() {
|
|
|
229
276
|
setThemeOverrides,
|
|
230
277
|
|
|
231
278
|
// HyperSkill
|
|
232
|
-
buildSkillJSON, buildHyperskillParam, loadFromParam,
|
|
279
|
+
buildSkillJSON, buildHyperskillParam, loadFromParam, loadFromUrl,
|
|
233
280
|
};
|
|
234
281
|
}
|
|
235
282
|
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas state store — Vanilla (framework-agnostic)
|
|
3
|
+
* Manages blocks on the canvas, mode, MCP connection, chat history
|
|
4
|
+
*
|
|
5
|
+
* This is the framework-agnostic version of the canvas store.
|
|
6
|
+
* For Svelte 5 reactivity, use @webmcp-auto-ui/sdk/canvas (Svelte runes)
|
|
7
|
+
* or @webmcp-auto-ui/ui/canvas (adapter).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { decode } from 'hyperskills';
|
|
11
|
+
|
|
12
|
+
export type BlockType =
|
|
13
|
+
| 'stat' | 'kv' | 'list' | 'chart' | 'alert' | 'code' | 'text' | 'actions' | 'tags'
|
|
14
|
+
| 'stat-card' | 'data-table' | 'timeline' | 'profile' | 'trombinoscope' | 'json-viewer'
|
|
15
|
+
| 'hemicycle' | 'chart-rich' | 'cards' | 'grid-data' | 'sankey' | 'map' | 'log'
|
|
16
|
+
| 'gallery' | 'carousel' | 'd3';
|
|
17
|
+
|
|
18
|
+
export type Mode = 'auto' | 'drag' | 'chat';
|
|
19
|
+
export type LLMId = 'haiku' | 'sonnet' | 'gemma-e2b' | 'gemma-e4b';
|
|
20
|
+
|
|
21
|
+
export interface Block {
|
|
22
|
+
id: string;
|
|
23
|
+
type: BlockType;
|
|
24
|
+
data: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChatMsg {
|
|
28
|
+
id: string;
|
|
29
|
+
role: 'user' | 'assistant' | 'system';
|
|
30
|
+
content: string;
|
|
31
|
+
thinking?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface McpToolInfo {
|
|
35
|
+
name: string;
|
|
36
|
+
description: string;
|
|
37
|
+
inputSchema?: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CanvasSnapshot {
|
|
41
|
+
blocks: Block[];
|
|
42
|
+
mode: Mode;
|
|
43
|
+
llm: LLMId;
|
|
44
|
+
mcpUrl: string;
|
|
45
|
+
mcpConnected: boolean;
|
|
46
|
+
mcpConnecting: boolean;
|
|
47
|
+
mcpName: string;
|
|
48
|
+
mcpTools: McpToolInfo[];
|
|
49
|
+
messages: ChatMsg[];
|
|
50
|
+
generating: boolean;
|
|
51
|
+
statusText: string;
|
|
52
|
+
statusColor: string;
|
|
53
|
+
themeOverrides: Record<string, string>;
|
|
54
|
+
blockCount: number;
|
|
55
|
+
isEmpty: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Listener = () => void;
|
|
59
|
+
|
|
60
|
+
function uuid() {
|
|
61
|
+
return 'b_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function msgId() {
|
|
65
|
+
return 'm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createCanvasVanilla() {
|
|
69
|
+
// ── Subscribers ────────────────────────────────────────────────────────
|
|
70
|
+
const listeners = new Set<Listener>();
|
|
71
|
+
function notify() { listeners.forEach(fn => fn()); }
|
|
72
|
+
|
|
73
|
+
// ── State ──────────────────────────────────────────────────────────────
|
|
74
|
+
let _blocks: Block[] = [];
|
|
75
|
+
let _mode: Mode = 'drag';
|
|
76
|
+
let _llm: LLMId = 'haiku';
|
|
77
|
+
let _mcpUrl = '';
|
|
78
|
+
let _mcpConnected = false;
|
|
79
|
+
let _mcpConnecting = false;
|
|
80
|
+
let _mcpName = '';
|
|
81
|
+
let _mcpTools: McpToolInfo[] = [];
|
|
82
|
+
let _messages: ChatMsg[] = [];
|
|
83
|
+
let _generating = false;
|
|
84
|
+
let _statusText = '● aucun MCP connecté';
|
|
85
|
+
let _statusColor = 'text-zinc-600';
|
|
86
|
+
let _themeOverrides: Record<string, string> = {};
|
|
87
|
+
|
|
88
|
+
// ── Block actions ──────────────────────────────────────────────────────
|
|
89
|
+
function addBlock(type: BlockType, data: Record<string, unknown> = {}): Block {
|
|
90
|
+
const block: Block = { id: uuid(), type, data };
|
|
91
|
+
_blocks = [..._blocks, block];
|
|
92
|
+
notify();
|
|
93
|
+
return block;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function removeBlock(id: string) {
|
|
97
|
+
_blocks = _blocks.filter((b) => b.id !== id);
|
|
98
|
+
notify();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function updateBlock(id: string, data: Partial<Record<string, unknown>>) {
|
|
102
|
+
_blocks = _blocks.map((b) => b.id === id ? { ...b, data: { ...b.data, ...data } } : b);
|
|
103
|
+
notify();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function moveBlock(fromId: string, toId: string) {
|
|
107
|
+
const fi = _blocks.findIndex((b) => b.id === fromId);
|
|
108
|
+
const ti = _blocks.findIndex((b) => b.id === toId);
|
|
109
|
+
if (fi < 0 || ti < 0 || fi === ti) return;
|
|
110
|
+
const next = [..._blocks];
|
|
111
|
+
const [moved] = next.splice(fi, 1);
|
|
112
|
+
next.splice(ti, 0, moved);
|
|
113
|
+
_blocks = next;
|
|
114
|
+
notify();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function clearBlocks() {
|
|
118
|
+
_blocks = [];
|
|
119
|
+
notify();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function setBlocks(newBlocks: Block[]) {
|
|
123
|
+
_blocks = newBlocks;
|
|
124
|
+
notify();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Chat ───────────────────────────────────────────────────────────────
|
|
128
|
+
function addMsg(role: ChatMsg['role'], content: string, thinking = false): ChatMsg {
|
|
129
|
+
const msg: ChatMsg = { id: msgId(), role, content, thinking };
|
|
130
|
+
_messages = [..._messages, msg];
|
|
131
|
+
notify();
|
|
132
|
+
return msg;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function updateMsg(id: string, content: string, thinking = false) {
|
|
136
|
+
_messages = _messages.map((m) => m.id === id ? { ...m, content, thinking } : m);
|
|
137
|
+
notify();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function clearMessages() {
|
|
141
|
+
_messages = [];
|
|
142
|
+
notify();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── MCP ────────────────────────────────────────────────────────────────
|
|
146
|
+
function setMcpConnecting(connecting: boolean) {
|
|
147
|
+
_mcpConnecting = connecting;
|
|
148
|
+
if (connecting) {
|
|
149
|
+
_statusText = '● connexion…';
|
|
150
|
+
_statusColor = 'text-amber-400';
|
|
151
|
+
}
|
|
152
|
+
notify();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function setMcpConnected(
|
|
156
|
+
connected: boolean,
|
|
157
|
+
name?: string,
|
|
158
|
+
tools?: McpToolInfo[]
|
|
159
|
+
) {
|
|
160
|
+
_mcpConnected = connected;
|
|
161
|
+
if (name) _mcpName = name;
|
|
162
|
+
if (tools) _mcpTools = tools;
|
|
163
|
+
if (connected) {
|
|
164
|
+
_statusText = `● ${name} · ${tools?.length ?? 0} tools`;
|
|
165
|
+
_statusColor = 'text-teal-400';
|
|
166
|
+
} else {
|
|
167
|
+
_statusText = '● aucun MCP connecté';
|
|
168
|
+
_statusColor = 'text-zinc-600';
|
|
169
|
+
}
|
|
170
|
+
notify();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function setMcpError(err: string) {
|
|
174
|
+
_mcpConnected = false;
|
|
175
|
+
_mcpConnecting = false;
|
|
176
|
+
_statusText = `● erreur: ${err}`;
|
|
177
|
+
_statusColor = 'text-red-400';
|
|
178
|
+
notify();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Theme ──────────────────────────────────────────────────────────────
|
|
182
|
+
function setThemeOverrides(overrides: Record<string, string>) {
|
|
183
|
+
_themeOverrides = overrides;
|
|
184
|
+
notify();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── HyperSkill ─────────────────────────────────────────────────────────
|
|
188
|
+
function buildSkillJSON() {
|
|
189
|
+
const skill: Record<string, unknown> = {
|
|
190
|
+
version: '1.0',
|
|
191
|
+
name: 'skill-' + Date.now(),
|
|
192
|
+
created: new Date().toISOString(),
|
|
193
|
+
mcp: _mcpUrl,
|
|
194
|
+
llm: _llm,
|
|
195
|
+
blocks: _blocks.map((b) => ({ type: b.type, data: b.data })),
|
|
196
|
+
};
|
|
197
|
+
if (Object.keys(_themeOverrides).length > 0) skill.theme = _themeOverrides;
|
|
198
|
+
return skill;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function buildHyperskillParam(): Promise<string> {
|
|
202
|
+
const json = JSON.stringify(buildSkillJSON());
|
|
203
|
+
const bytes = new TextEncoder().encode(json);
|
|
204
|
+
// Auto-compress with gzip when payload exceeds 6 KB to keep URLs under nginx limits
|
|
205
|
+
if (bytes.length > 6144) {
|
|
206
|
+
const cs = new CompressionStream('gzip');
|
|
207
|
+
const writer = cs.writable.getWriter();
|
|
208
|
+
writer.write(bytes);
|
|
209
|
+
writer.close();
|
|
210
|
+
const compressed = new Uint8Array(await new Response(cs.readable).arrayBuffer());
|
|
211
|
+
const b64 = btoa(String.fromCharCode(...compressed))
|
|
212
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
213
|
+
return 'gz.' + b64;
|
|
214
|
+
}
|
|
215
|
+
// Small payloads: plain base64url
|
|
216
|
+
return btoa(unescape(encodeURIComponent(json)))
|
|
217
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function loadFromParam(param: string): Promise<boolean> {
|
|
221
|
+
try {
|
|
222
|
+
let json: string;
|
|
223
|
+
|
|
224
|
+
if (param.startsWith('gz.')) {
|
|
225
|
+
let b64 = param.slice(3).replace(/-/g, '+').replace(/_/g, '/');
|
|
226
|
+
while (b64.length % 4) b64 += '=';
|
|
227
|
+
const compressed = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
228
|
+
const ds = new DecompressionStream('gzip');
|
|
229
|
+
const writer = ds.writable.getWriter();
|
|
230
|
+
writer.write(compressed);
|
|
231
|
+
writer.close();
|
|
232
|
+
json = await new Response(ds.readable).text();
|
|
233
|
+
} else {
|
|
234
|
+
let b64 = param.replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/');
|
|
235
|
+
while (b64.length % 4) b64 += '=';
|
|
236
|
+
json = decodeURIComponent(escape(atob(b64)));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const skill = JSON.parse(json) as {
|
|
240
|
+
mcp?: string; llm?: LLMId;
|
|
241
|
+
theme?: Record<string, string>;
|
|
242
|
+
blocks?: { type: BlockType; data: Record<string, unknown> }[];
|
|
243
|
+
};
|
|
244
|
+
if (skill.mcp) _mcpUrl = skill.mcp;
|
|
245
|
+
if (skill.llm) _llm = skill.llm;
|
|
246
|
+
if (skill.theme) _themeOverrides = skill.theme;
|
|
247
|
+
if (skill.blocks) {
|
|
248
|
+
_blocks = skill.blocks.map((b) => ({ id: uuid(), type: b.type, data: b.data }));
|
|
249
|
+
}
|
|
250
|
+
notify();
|
|
251
|
+
return true;
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── loadFromUrl ────────────────────────────────────────────────────────
|
|
258
|
+
async function loadFromUrl(url: string): Promise<boolean> {
|
|
259
|
+
try {
|
|
260
|
+
const { content: raw } = await decode(url);
|
|
261
|
+
const decoded = JSON.parse(raw) as { meta?: Record<string, unknown>; content?: { blocks?: { type: BlockType; data: Record<string, unknown> }[] } };
|
|
262
|
+
if (decoded.meta?.mcp) _mcpUrl = decoded.meta.mcp as string;
|
|
263
|
+
if (decoded.meta?.llm) _llm = decoded.meta.llm as LLMId;
|
|
264
|
+
if (decoded.meta?.theme) _themeOverrides = decoded.meta.theme as Record<string, string>;
|
|
265
|
+
if (decoded.content?.blocks) _blocks = decoded.content.blocks.map((b) => ({ id: uuid(), type: b.type, data: b.data }));
|
|
266
|
+
notify();
|
|
267
|
+
return true;
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Snapshot ────────────────────────────────────────────────────────────
|
|
274
|
+
function getSnapshot(): CanvasSnapshot {
|
|
275
|
+
return {
|
|
276
|
+
blocks: _blocks,
|
|
277
|
+
mode: _mode,
|
|
278
|
+
llm: _llm,
|
|
279
|
+
mcpUrl: _mcpUrl,
|
|
280
|
+
mcpConnected: _mcpConnected,
|
|
281
|
+
mcpConnecting: _mcpConnecting,
|
|
282
|
+
mcpName: _mcpName,
|
|
283
|
+
mcpTools: _mcpTools,
|
|
284
|
+
messages: _messages,
|
|
285
|
+
generating: _generating,
|
|
286
|
+
statusText: _statusText,
|
|
287
|
+
statusColor: _statusColor,
|
|
288
|
+
themeOverrides: _themeOverrides,
|
|
289
|
+
blockCount: _blocks.length,
|
|
290
|
+
isEmpty: _blocks.length === 0,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Subscribe ──────────────────────────────────────────────────────────
|
|
295
|
+
function subscribe(fn: Listener): () => void {
|
|
296
|
+
listeners.add(fn);
|
|
297
|
+
return () => { listeners.delete(fn); };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Return public API ──────────────────────────────────────────────────
|
|
301
|
+
return {
|
|
302
|
+
// State getters + setters
|
|
303
|
+
get blocks() { return _blocks; },
|
|
304
|
+
get mode() { return _mode; },
|
|
305
|
+
set mode(v: Mode) { _mode = v; notify(); },
|
|
306
|
+
get llm() { return _llm; },
|
|
307
|
+
set llm(v: LLMId) { _llm = v; notify(); },
|
|
308
|
+
get mcpUrl() { return _mcpUrl; },
|
|
309
|
+
set mcpUrl(v: string) { _mcpUrl = v; notify(); },
|
|
310
|
+
get mcpConnected() { return _mcpConnected; },
|
|
311
|
+
get mcpConnecting() { return _mcpConnecting; },
|
|
312
|
+
get mcpName() { return _mcpName; },
|
|
313
|
+
get mcpTools() { return _mcpTools; },
|
|
314
|
+
get messages() { return _messages; },
|
|
315
|
+
get generating() { return _generating; },
|
|
316
|
+
set generating(v: boolean) { _generating = v; notify(); },
|
|
317
|
+
get statusText() { return _statusText; },
|
|
318
|
+
get statusColor() { return _statusColor; },
|
|
319
|
+
get blockCount() { return _blocks.length; },
|
|
320
|
+
get isEmpty() { return _blocks.length === 0; },
|
|
321
|
+
|
|
322
|
+
// Setters (kept for backward compat)
|
|
323
|
+
setMode(m: Mode) { _mode = m; notify(); },
|
|
324
|
+
setLlm(l: LLMId) { _llm = l; notify(); },
|
|
325
|
+
setMcpUrl(u: string) { _mcpUrl = u; notify(); },
|
|
326
|
+
setGenerating(g: boolean) { _generating = g; notify(); },
|
|
327
|
+
|
|
328
|
+
// Block actions
|
|
329
|
+
addBlock, removeBlock, updateBlock, moveBlock, clearBlocks, setBlocks,
|
|
330
|
+
|
|
331
|
+
// Chat
|
|
332
|
+
addMsg, updateMsg, clearMessages,
|
|
333
|
+
|
|
334
|
+
// MCP
|
|
335
|
+
setMcpConnecting, setMcpConnected, setMcpError,
|
|
336
|
+
|
|
337
|
+
// Theme
|
|
338
|
+
get themeOverrides() { return _themeOverrides; },
|
|
339
|
+
setThemeOverrides,
|
|
340
|
+
|
|
341
|
+
// HyperSkill
|
|
342
|
+
buildSkillJSON, buildHyperskillParam, loadFromParam, loadFromUrl,
|
|
343
|
+
|
|
344
|
+
// Framework-agnostic reactivity
|
|
345
|
+
subscribe,
|
|
346
|
+
getSnapshot,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export const canvasVanilla = createCanvasVanilla();
|
package/tests/hyperskill.test.ts
CHANGED
|
@@ -1,74 +1,75 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { hash, diff, decode, encode } from '../src/index.js';
|
|
3
3
|
|
|
4
|
-
describe('
|
|
4
|
+
describe('hash', () => {
|
|
5
5
|
it('returns a 64-char hex SHA-256', async () => {
|
|
6
|
-
const
|
|
7
|
-
expect(
|
|
6
|
+
const h = await hash('https://example.com', JSON.stringify({ foo: 'bar' }));
|
|
7
|
+
expect(h).toMatch(/^[0-9a-f]{64}$/);
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
it('is deterministic', async () => {
|
|
11
|
-
const a = await
|
|
12
|
-
const b = await
|
|
11
|
+
const a = await hash('https://x.com', JSON.stringify({ n: 1 }));
|
|
12
|
+
const b = await hash('https://x.com', JSON.stringify({ n: 1 }));
|
|
13
13
|
expect(a).toBe(b);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
it('differs for different inputs', async () => {
|
|
17
|
-
const a = await
|
|
18
|
-
const b = await
|
|
17
|
+
const a = await hash('https://x.com', JSON.stringify({ n: 1 }));
|
|
18
|
+
const b = await hash('https://x.com', JSON.stringify({ n: 2 }));
|
|
19
19
|
expect(a).not.toBe(b);
|
|
20
20
|
});
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
describe('
|
|
23
|
+
describe('diff', () => {
|
|
24
24
|
it('returns empty for identical objects', () => {
|
|
25
|
-
expect(
|
|
25
|
+
expect(diff({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toEqual([]);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
it('detects changed keys', () => {
|
|
29
|
-
const changed =
|
|
29
|
+
const changed = diff({ a: 1, b: 2 }, { a: 1, b: 3 });
|
|
30
30
|
expect(changed).toContain('b');
|
|
31
31
|
expect(changed).not.toContain('a');
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
it('detects added keys', () => {
|
|
35
|
-
const changed =
|
|
35
|
+
const changed = diff({ a: 1 }, { a: 1, b: 2 });
|
|
36
36
|
expect(changed).toContain('b');
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
it('detects removed keys', () => {
|
|
40
|
-
const changed =
|
|
40
|
+
const changed = diff({ a: 1, b: 2 }, { a: 1 });
|
|
41
41
|
expect(changed).toContain('b');
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
it('handles non-object inputs', () => {
|
|
45
|
-
expect(
|
|
46
|
-
expect(
|
|
45
|
+
expect(diff('x', 'x')).toEqual([]);
|
|
46
|
+
expect(diff('x', 'y')).toContain('(root)');
|
|
47
47
|
});
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
describe('
|
|
50
|
+
describe('encode / decode', () => {
|
|
51
51
|
// jsdom provides TextEncoder/TextDecoder and crypto.subtle
|
|
52
52
|
it('round-trips a skill', async () => {
|
|
53
53
|
const skill = {
|
|
54
54
|
meta: { title: 'test', mcp: 'https://mcp.example.com', mcpName: 'example' },
|
|
55
55
|
content: { blocks: [{ type: 'stat', data: { label: 'KPI', value: '42' } }] },
|
|
56
56
|
};
|
|
57
|
-
const url = await
|
|
57
|
+
const url = await encode('https://example.com/viewer', JSON.stringify(skill));
|
|
58
58
|
expect(url).toContain('?hs=');
|
|
59
59
|
|
|
60
|
-
const
|
|
60
|
+
const { content: raw } = await decode(url);
|
|
61
|
+
const decoded = JSON.parse(raw);
|
|
61
62
|
expect(decoded.meta.title).toBe('test');
|
|
62
63
|
expect(decoded.meta.mcp).toBe('https://mcp.example.com');
|
|
63
|
-
|
|
64
|
-
expect(content.blocks[0].type).toBe('stat');
|
|
64
|
+
expect(decoded.content.blocks[0].type).toBe('stat');
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
it('decodes from raw base64 param', async () => {
|
|
68
68
|
const skill = { meta: { title: 'raw' }, content: { x: 1 } };
|
|
69
|
-
const url = await
|
|
69
|
+
const url = await encode('https://example.com', JSON.stringify(skill));
|
|
70
70
|
const param = new URL(url).searchParams.get('hs')!;
|
|
71
|
-
const
|
|
71
|
+
const { content: raw } = await decode(param);
|
|
72
|
+
const decoded = JSON.parse(raw);
|
|
72
73
|
expect(decoded.meta.title).toBe('raw');
|
|
73
74
|
});
|
|
74
75
|
});
|
package/src/hyperskill/format.ts
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HyperSkill format — spec: https://hyperskills.net/
|
|
3
|
-
* URL: https://example.com/page?hs=base64(content)
|
|
4
|
-
* Compression: prefix "gz." for skills > 6KB
|
|
5
|
-
* Traceability: SHA-256(source_url + content), chainable
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export interface HyperSkillMeta {
|
|
9
|
-
title?: string;
|
|
10
|
-
description?: string;
|
|
11
|
-
version?: string;
|
|
12
|
-
created?: string;
|
|
13
|
-
mcp?: string;
|
|
14
|
-
mcpName?: string;
|
|
15
|
-
llm?: string;
|
|
16
|
-
tags?: string[];
|
|
17
|
-
theme?: Record<string, string>;
|
|
18
|
-
hash?: string;
|
|
19
|
-
previousHash?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface HyperSkill {
|
|
23
|
-
meta: HyperSkillMeta;
|
|
24
|
-
content: unknown;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface HyperSkillVersion {
|
|
28
|
-
hash: string;
|
|
29
|
-
previousHash?: string;
|
|
30
|
-
timestamp: number;
|
|
31
|
-
skill: HyperSkill;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export async function encodeHyperSkill(
|
|
35
|
-
skill: HyperSkill,
|
|
36
|
-
sourceUrl?: string
|
|
37
|
-
): Promise<string> {
|
|
38
|
-
const base = sourceUrl ?? (typeof window !== 'undefined' ? window.location.href.split('?')[0] : 'https://example.com');
|
|
39
|
-
const json = JSON.stringify(skill);
|
|
40
|
-
const bytes = new TextEncoder().encode(json);
|
|
41
|
-
let param: string;
|
|
42
|
-
|
|
43
|
-
if (bytes.length > 6144 && typeof CompressionStream !== 'undefined') {
|
|
44
|
-
try {
|
|
45
|
-
const cs = new CompressionStream('gzip');
|
|
46
|
-
const writer = cs.writable.getWriter();
|
|
47
|
-
writer.write(bytes);
|
|
48
|
-
writer.close();
|
|
49
|
-
const compressed = await new Response(cs.readable).arrayBuffer();
|
|
50
|
-
const b64 = btoa(String.fromCharCode(...new Uint8Array(compressed)));
|
|
51
|
-
param = 'gz.' + b64;
|
|
52
|
-
} catch {
|
|
53
|
-
param = btoa(unescape(encodeURIComponent(json)));
|
|
54
|
-
}
|
|
55
|
-
} else {
|
|
56
|
-
param = btoa(unescape(encodeURIComponent(json)));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const url = new URL(base);
|
|
60
|
-
url.searchParams.set('hs', param);
|
|
61
|
-
return url.toString();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export async function decodeHyperSkill(urlOrParam: string): Promise<HyperSkill> {
|
|
65
|
-
let param: string;
|
|
66
|
-
if (urlOrParam.includes('=') || urlOrParam.includes('://') || urlOrParam.includes('?')) {
|
|
67
|
-
try {
|
|
68
|
-
const url = new URL(urlOrParam, typeof window !== 'undefined' ? window.location.href : 'https://example.com');
|
|
69
|
-
param = url.searchParams.get('hs') ?? urlOrParam;
|
|
70
|
-
} catch {
|
|
71
|
-
param = urlOrParam;
|
|
72
|
-
}
|
|
73
|
-
} else {
|
|
74
|
-
param = urlOrParam;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (param.startsWith('gz.') && typeof DecompressionStream !== 'undefined') {
|
|
78
|
-
const b64 = param.slice(3);
|
|
79
|
-
const binary = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
80
|
-
const ds = new DecompressionStream('gzip');
|
|
81
|
-
const writer = ds.writable.getWriter();
|
|
82
|
-
writer.write(binary);
|
|
83
|
-
writer.close();
|
|
84
|
-
const json = await new Response(ds.readable).text();
|
|
85
|
-
return JSON.parse(json) as HyperSkill;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const json = decodeURIComponent(escape(atob(param)));
|
|
89
|
-
return JSON.parse(json) as HyperSkill;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export async function computeHash(sourceUrl: string, content: unknown): Promise<string> {
|
|
93
|
-
const text = sourceUrl + JSON.stringify(content);
|
|
94
|
-
const bytes = new TextEncoder().encode(text);
|
|
95
|
-
const buf = await crypto.subtle.digest('SHA-256', bytes);
|
|
96
|
-
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export async function createVersion(
|
|
100
|
-
skill: HyperSkill,
|
|
101
|
-
sourceUrl: string,
|
|
102
|
-
previousHash?: string
|
|
103
|
-
): Promise<HyperSkillVersion> {
|
|
104
|
-
const hash = await computeHash(sourceUrl, skill.content);
|
|
105
|
-
return {
|
|
106
|
-
hash,
|
|
107
|
-
previousHash,
|
|
108
|
-
timestamp: Date.now(),
|
|
109
|
-
skill: { ...skill, meta: { ...skill.meta, hash, previousHash } },
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function getHsParam(): string | null {
|
|
114
|
-
if (typeof window === 'undefined') return null;
|
|
115
|
-
return new URLSearchParams(window.location.search).get('hs');
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function diffSkills(prev: unknown, next: unknown): string[] {
|
|
119
|
-
if (typeof prev !== 'object' || typeof next !== 'object' || !prev || !next) {
|
|
120
|
-
return prev !== next ? ['(root)'] : [];
|
|
121
|
-
}
|
|
122
|
-
const p = prev as Record<string, unknown>;
|
|
123
|
-
const n = next as Record<string, unknown>;
|
|
124
|
-
const keys = new Set([...Object.keys(p), ...Object.keys(n)]);
|
|
125
|
-
const changed: string[] = [];
|
|
126
|
-
for (const k of keys) {
|
|
127
|
-
if (JSON.stringify(p[k]) !== JSON.stringify(n[k])) changed.push(k);
|
|
128
|
-
}
|
|
129
|
-
return changed;
|
|
130
|
-
}
|