@webmcp-auto-ui/sdk 0.1.1 → 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 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 a list of blocks with their data, plus metadata (MCP server, LLM, tags). It encodes to a URL parameter:
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 { encodeHyperSkill, decodeHyperSkill, computeHash, diffSkills } from '@webmcp-auto-ui/sdk';
18
+ import { encode, decode, hash, diff, getHsParam } from '@webmcp-auto-ui/sdk';
17
19
 
18
- const url = await encodeHyperSkill(skill, 'https://example.com/viewer');
19
- const skill = await decodeHyperSkill(url); // or pass raw ?hs= param
20
- const hash = await computeHash(sourceUrl, skill.content);
21
- const changed = diffSkills(prev.content, next.content); // ['blocks', 'meta']
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.1.1",
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
  },
@@ -0,0 +1,3 @@
1
+ // Canvas store — Vanilla (framework-agnostic), no Svelte dependency
2
+ export { canvasVanilla } from './stores/canvas.js';
3
+ export type { Block, BlockType, Mode, LLMId, ChatMsg, McpToolInfo, CanvasSnapshot } from './stores/canvas.js';
package/src/index.ts CHANGED
@@ -1,19 +1,74 @@
1
1
  // @webmcp-auto-ui/sdk — public API
2
2
 
3
- // HyperSkill format
4
- export {
5
- encodeHyperSkill,
6
- decodeHyperSkill,
7
- computeHash,
8
- createVersion,
9
- getHsParam,
10
- diffSkills,
11
- } from './hyperskill/format.js';
12
- export type {
13
- HyperSkill,
14
- HyperSkillMeta,
15
- HyperSkillVersion,
16
- } from './hyperskill/format.js';
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
+ ];
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Skills registry — in-memory CRUD for UI recipes/skills.
3
- * Each skill describes how to compose a UI from a set of blocks.
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 {
@@ -8,6 +8,10 @@ export interface SkillBlock {
8
8
  data: Record<string, unknown>;
9
9
  }
10
10
 
11
+ export interface ThemeOverrides {
12
+ [key: string]: string;
13
+ }
14
+
11
15
  export interface Skill {
12
16
  id: string;
13
17
  name: string;
@@ -16,6 +20,7 @@ export interface Skill {
16
20
  mcpName?: string;
17
21
  llm?: string;
18
22
  tags?: string[];
23
+ theme?: ThemeOverrides;
19
24
  blocks: SkillBlock[];
20
25
  createdAt: number;
21
26
  updatedAt: number;
@@ -3,13 +3,16 @@
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'
9
- | 'hemicycle' | 'chart-rich' | 'cards' | 'grid-data' | 'sankey' | 'map' | 'log';
11
+ | 'hemicycle' | 'chart-rich' | 'cards' | 'grid-data' | 'sankey' | 'map' | 'log'
12
+ | 'gallery' | 'carousel' | 'd3';
10
13
 
11
14
  export type Mode = 'auto' | 'drag' | 'chat';
12
- export type LLMId = 'haiku' | 'sonnet' | 'gemma-e2b';
15
+ export type LLMId = 'haiku' | 'sonnet' | 'gemma-e2b' | 'gemma-e4b';
13
16
 
14
17
  export interface Block {
15
18
  id: string;
@@ -41,7 +44,7 @@ function msgId() {
41
44
  function createCanvas() {
42
45
  // ── State ────────────────────────────────────────────────────────────────
43
46
  let blocks = $state<Block[]>([]);
44
- let mode = $state<Mode>('auto');
47
+ let mode = $state<Mode>('drag');
45
48
  let llm = $state<LLMId>('haiku');
46
49
  let mcpUrl = $state('');
47
50
  let mcpConnected = $state(false);
@@ -134,13 +137,20 @@ function createCanvas() {
134
137
  function setMcpError(err: string) {
135
138
  mcpConnected = false;
136
139
  mcpConnecting = false;
137
- statusText = `● erreur: ${err.slice(0, 40)}`;
140
+ statusText = `● erreur: ${err}`;
138
141
  statusColor = 'text-red-400';
139
142
  }
140
143
 
144
+ // ── Theme ────────────────────────────────────────────────────────────────
145
+ let themeOverrides = $state<Record<string, string>>({});
146
+
147
+ function setThemeOverrides(overrides: Record<string, string>) {
148
+ themeOverrides = overrides;
149
+ }
150
+
141
151
  // ── HyperSkill ───────────────────────────────────────────────────────────
142
152
  function buildSkillJSON() {
143
- return {
153
+ const skill: Record<string, unknown> = {
144
154
  version: '1.0',
145
155
  name: 'skill-' + Date.now(),
146
156
  created: new Date().toISOString(),
@@ -148,22 +158,58 @@ function createCanvas() {
148
158
  llm,
149
159
  blocks: blocks.map((b) => ({ type: b.type, data: b.data })),
150
160
  };
161
+ if (Object.keys(themeOverrides).length > 0) skill.theme = themeOverrides;
162
+ return skill;
151
163
  }
152
164
 
153
- function buildHyperskillParam(): string {
165
+ async function buildHyperskillParam(): Promise<string> {
154
166
  const json = JSON.stringify(buildSkillJSON());
155
- return btoa(unescape(encodeURIComponent(json)));
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
180
+ return btoa(unescape(encodeURIComponent(json)))
181
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
156
182
  }
157
183
 
158
- function loadFromParam(param: string): boolean {
184
+ async function loadFromParam(param: string): Promise<boolean> {
159
185
  try {
160
- const json = decodeURIComponent(escape(atob(param)));
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
+ }
204
+
161
205
  const skill = JSON.parse(json) as {
162
206
  mcp?: string; llm?: LLMId;
207
+ theme?: Record<string, string>;
163
208
  blocks?: { type: BlockType; data: Record<string, unknown> }[];
164
209
  };
165
210
  if (skill.mcp) mcpUrl = skill.mcp;
166
211
  if (skill.llm) llm = skill.llm;
212
+ if (skill.theme) themeOverrides = skill.theme;
167
213
  if (skill.blocks) {
168
214
  blocks = skill.blocks.map((b) => ({ id: uuid(), type: b.type, data: b.data }));
169
215
  }
@@ -173,25 +219,44 @@ function createCanvas() {
173
219
  }
174
220
  }
175
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
+
176
237
  // ── Return public API ────────────────────────────────────────────────────
177
238
  return {
178
- // State getters (reactive)
239
+ // State getters + setters (reactive — supports bind:)
179
240
  get blocks() { return blocks; },
180
241
  get mode() { return mode; },
242
+ set mode(v: Mode) { mode = v; },
181
243
  get llm() { return llm; },
244
+ set llm(v: LLMId) { llm = v; },
182
245
  get mcpUrl() { return mcpUrl; },
246
+ set mcpUrl(v: string) { mcpUrl = v; },
183
247
  get mcpConnected() { return mcpConnected; },
184
248
  get mcpConnecting() { return mcpConnecting; },
185
249
  get mcpName() { return mcpName; },
186
250
  get mcpTools() { return mcpTools; },
187
251
  get messages() { return messages; },
188
252
  get generating() { return generating; },
253
+ set generating(v: boolean) { generating = v; },
189
254
  get statusText() { return statusText; },
190
255
  get statusColor() { return statusColor; },
191
256
  get blockCount() { return blockCount; },
192
257
  get isEmpty() { return isEmpty; },
193
258
 
194
- // Setters
259
+ // Setters (kept for backward compat)
195
260
  setMode(m: Mode) { mode = m; },
196
261
  setLlm(l: LLMId) { llm = l; },
197
262
  setMcpUrl(u: string) { mcpUrl = u; },
@@ -206,8 +271,12 @@ function createCanvas() {
206
271
  // MCP
207
272
  setMcpConnecting, setMcpConnected, setMcpError,
208
273
 
274
+ // Theme
275
+ get themeOverrides() { return themeOverrides; },
276
+ setThemeOverrides,
277
+
209
278
  // HyperSkill
210
- buildSkillJSON, buildHyperskillParam, loadFromParam,
279
+ buildSkillJSON, buildHyperskillParam, loadFromParam, loadFromUrl,
211
280
  };
212
281
  }
213
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();
@@ -1,74 +1,75 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { computeHash, diffSkills, decodeHyperSkill, encodeHyperSkill } from '../src/hyperskill/format.js';
2
+ import { hash, diff, decode, encode } from '../src/index.js';
3
3
 
4
- describe('computeHash', () => {
4
+ describe('hash', () => {
5
5
  it('returns a 64-char hex SHA-256', async () => {
6
- const hash = await computeHash('https://example.com', { foo: 'bar' });
7
- expect(hash).toMatch(/^[0-9a-f]{64}$/);
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 computeHash('https://x.com', { n: 1 });
12
- const b = await computeHash('https://x.com', { n: 1 });
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 computeHash('https://x.com', { n: 1 });
18
- const b = await computeHash('https://x.com', { n: 2 });
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('diffSkills', () => {
23
+ describe('diff', () => {
24
24
  it('returns empty for identical objects', () => {
25
- expect(diffSkills({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toEqual([]);
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 = diffSkills({ a: 1, b: 2 }, { a: 1, b: 3 });
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 = diffSkills({ a: 1 }, { a: 1, b: 2 });
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 = diffSkills({ a: 1, b: 2 }, { a: 1 });
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(diffSkills('x', 'x')).toEqual([]);
46
- expect(diffSkills('x', 'y')).toContain('(root)');
45
+ expect(diff('x', 'x')).toEqual([]);
46
+ expect(diff('x', 'y')).toContain('(root)');
47
47
  });
48
48
  });
49
49
 
50
- describe('encodeHyperSkill / decodeHyperSkill', () => {
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 encodeHyperSkill(skill, 'https://example.com/viewer');
57
+ const url = await encode('https://example.com/viewer', JSON.stringify(skill));
58
58
  expect(url).toContain('?hs=');
59
59
 
60
- const decoded = await decodeHyperSkill(url);
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
- const content = decoded.content as typeof skill.content;
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 encodeHyperSkill(skill, 'https://example.com');
69
+ const url = await encode('https://example.com', JSON.stringify(skill));
70
70
  const param = new URL(url).searchParams.get('hs')!;
71
- const decoded = await decodeHyperSkill(param);
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
  });
@@ -1,129 +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
- hash?: string;
18
- previousHash?: string;
19
- }
20
-
21
- export interface HyperSkill {
22
- meta: HyperSkillMeta;
23
- content: unknown;
24
- }
25
-
26
- export interface HyperSkillVersion {
27
- hash: string;
28
- previousHash?: string;
29
- timestamp: number;
30
- skill: HyperSkill;
31
- }
32
-
33
- export async function encodeHyperSkill(
34
- skill: HyperSkill,
35
- sourceUrl?: string
36
- ): Promise<string> {
37
- const base = sourceUrl ?? (typeof window !== 'undefined' ? window.location.href.split('?')[0] : 'https://example.com');
38
- const json = JSON.stringify(skill);
39
- const bytes = new TextEncoder().encode(json);
40
- let param: string;
41
-
42
- if (bytes.length > 6144 && typeof CompressionStream !== 'undefined') {
43
- try {
44
- const cs = new CompressionStream('gzip');
45
- const writer = cs.writable.getWriter();
46
- writer.write(bytes);
47
- writer.close();
48
- const compressed = await new Response(cs.readable).arrayBuffer();
49
- const b64 = btoa(String.fromCharCode(...new Uint8Array(compressed)));
50
- param = 'gz.' + b64;
51
- } catch {
52
- param = btoa(unescape(encodeURIComponent(json)));
53
- }
54
- } else {
55
- param = btoa(unescape(encodeURIComponent(json)));
56
- }
57
-
58
- const url = new URL(base);
59
- url.searchParams.set('hs', param);
60
- return url.toString();
61
- }
62
-
63
- export async function decodeHyperSkill(urlOrParam: string): Promise<HyperSkill> {
64
- let param: string;
65
- if (urlOrParam.includes('=') || urlOrParam.includes('://') || urlOrParam.includes('?')) {
66
- try {
67
- const url = new URL(urlOrParam, typeof window !== 'undefined' ? window.location.href : 'https://example.com');
68
- param = url.searchParams.get('hs') ?? urlOrParam;
69
- } catch {
70
- param = urlOrParam;
71
- }
72
- } else {
73
- param = urlOrParam;
74
- }
75
-
76
- if (param.startsWith('gz.') && typeof DecompressionStream !== 'undefined') {
77
- const b64 = param.slice(3);
78
- const binary = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
79
- const ds = new DecompressionStream('gzip');
80
- const writer = ds.writable.getWriter();
81
- writer.write(binary);
82
- writer.close();
83
- const json = await new Response(ds.readable).text();
84
- return JSON.parse(json) as HyperSkill;
85
- }
86
-
87
- const json = decodeURIComponent(escape(atob(param)));
88
- return JSON.parse(json) as HyperSkill;
89
- }
90
-
91
- export async function computeHash(sourceUrl: string, content: unknown): Promise<string> {
92
- const text = sourceUrl + JSON.stringify(content);
93
- const bytes = new TextEncoder().encode(text);
94
- const buf = await crypto.subtle.digest('SHA-256', bytes);
95
- return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('');
96
- }
97
-
98
- export async function createVersion(
99
- skill: HyperSkill,
100
- sourceUrl: string,
101
- previousHash?: string
102
- ): Promise<HyperSkillVersion> {
103
- const hash = await computeHash(sourceUrl, skill.content);
104
- return {
105
- hash,
106
- previousHash,
107
- timestamp: Date.now(),
108
- skill: { ...skill, meta: { ...skill.meta, hash, previousHash } },
109
- };
110
- }
111
-
112
- export function getHsParam(): string | null {
113
- if (typeof window === 'undefined') return null;
114
- return new URLSearchParams(window.location.search).get('hs');
115
- }
116
-
117
- export function diffSkills(prev: unknown, next: unknown): string[] {
118
- if (typeof prev !== 'object' || typeof next !== 'object' || !prev || !next) {
119
- return prev !== next ? ['(root)'] : [];
120
- }
121
- const p = prev as Record<string, unknown>;
122
- const n = next as Record<string, unknown>;
123
- const keys = new Set([...Object.keys(p), ...Object.keys(n)]);
124
- const changed: string[] = [];
125
- for (const k of keys) {
126
- if (JSON.stringify(p[k]) !== JSON.stringify(n[k])) changed.push(k);
127
- }
128
- return changed;
129
- }