freesail 0.0.1 → 0.1.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.
Files changed (54) hide show
  1. package/README.md +190 -5
  2. package/docs/A2UX_Protocol.md +183 -0
  3. package/docs/Agents.md +218 -0
  4. package/docs/Architecture.md +285 -0
  5. package/docs/CatalogReference.md +377 -0
  6. package/docs/GettingStarted.md +230 -0
  7. package/examples/demo/package.json +21 -0
  8. package/examples/demo/public/index.html +381 -0
  9. package/examples/demo/server.js +253 -0
  10. package/package.json +38 -5
  11. package/packages/core/package.json +48 -0
  12. package/packages/core/src/functions.ts +403 -0
  13. package/packages/core/src/index.ts +214 -0
  14. package/packages/core/src/parser.ts +270 -0
  15. package/packages/core/src/protocol.ts +254 -0
  16. package/packages/core/src/store.ts +452 -0
  17. package/packages/core/src/transport.ts +439 -0
  18. package/packages/core/src/types.ts +209 -0
  19. package/packages/core/tsconfig.json +10 -0
  20. package/packages/lit-ui/package.json +44 -0
  21. package/packages/lit-ui/src/catalogs/standard/catalog.json +405 -0
  22. package/packages/lit-ui/src/catalogs/standard/elements/Badge.ts +96 -0
  23. package/packages/lit-ui/src/catalogs/standard/elements/Button.ts +147 -0
  24. package/packages/lit-ui/src/catalogs/standard/elements/Card.ts +78 -0
  25. package/packages/lit-ui/src/catalogs/standard/elements/Checkbox.ts +94 -0
  26. package/packages/lit-ui/src/catalogs/standard/elements/Column.ts +66 -0
  27. package/packages/lit-ui/src/catalogs/standard/elements/Divider.ts +59 -0
  28. package/packages/lit-ui/src/catalogs/standard/elements/Image.ts +54 -0
  29. package/packages/lit-ui/src/catalogs/standard/elements/Input.ts +125 -0
  30. package/packages/lit-ui/src/catalogs/standard/elements/Progress.ts +79 -0
  31. package/packages/lit-ui/src/catalogs/standard/elements/Row.ts +68 -0
  32. package/packages/lit-ui/src/catalogs/standard/elements/Select.ts +110 -0
  33. package/packages/lit-ui/src/catalogs/standard/elements/Spacer.ts +37 -0
  34. package/packages/lit-ui/src/catalogs/standard/elements/Spinner.ts +76 -0
  35. package/packages/lit-ui/src/catalogs/standard/elements/Text.ts +86 -0
  36. package/packages/lit-ui/src/catalogs/standard/elements/index.ts +18 -0
  37. package/packages/lit-ui/src/catalogs/standard/index.ts +17 -0
  38. package/packages/lit-ui/src/index.ts +84 -0
  39. package/packages/lit-ui/src/renderer.ts +211 -0
  40. package/packages/lit-ui/src/types.ts +49 -0
  41. package/packages/lit-ui/src/utils/define-props.ts +157 -0
  42. package/packages/lit-ui/src/utils/index.ts +2 -0
  43. package/packages/lit-ui/src/utils/registry.ts +139 -0
  44. package/packages/lit-ui/tsconfig.json +11 -0
  45. package/packages/server/package.json +61 -0
  46. package/packages/server/src/adapters/index.ts +5 -0
  47. package/packages/server/src/adapters/langchain.ts +175 -0
  48. package/packages/server/src/adapters/openai.ts +209 -0
  49. package/packages/server/src/catalog-loader.ts +311 -0
  50. package/packages/server/src/index.ts +142 -0
  51. package/packages/server/src/stream.ts +329 -0
  52. package/packages/server/tsconfig.json +11 -0
  53. package/tsconfig.base.json +23 -0
  54. package/index.js +0 -3
@@ -0,0 +1,230 @@
1
+ # Getting Started with Freesail
2
+
3
+ This guide walks you through setting up a Freesail-powered generative UI application from scratch.
4
+
5
+ ## Prerequisites
6
+
7
+ - Node.js 18+
8
+ - npm or yarn
9
+
10
+ ## Installation
11
+
12
+ ### 1. Create a new project
13
+
14
+ ```bash
15
+ mkdir my-freesail-app
16
+ cd my-freesail-app
17
+ npm init -y
18
+ ```
19
+
20
+ ### 2. Install Freesail packages
21
+
22
+ ```bash
23
+ # Core packages
24
+ npm install @freesail/core @freesail/server
25
+
26
+ # Optional: Lit UI components
27
+ npm install @freesail/lit-ui
28
+
29
+ # Server dependencies
30
+ npm install express
31
+ ```
32
+
33
+ ## Basic Server Setup
34
+
35
+ Create a simple Express server with SSE streaming:
36
+
37
+ ```typescript
38
+ // server.ts
39
+ import express from 'express';
40
+ import { FreesailStream, CatalogToToolConverter } from '@freesail/server';
41
+
42
+ const app = express();
43
+
44
+ // SSE endpoint
45
+ app.get('/api/stream', (req, res) => {
46
+ const stream = new FreesailStream({ response: res });
47
+
48
+ // Create a UI surface
49
+ stream.createSurface('main', 'standard_catalog_v1');
50
+
51
+ // Send initial UI
52
+ stream.send({
53
+ updateComponents: {
54
+ surfaceId: 'main',
55
+ components: [
56
+ {
57
+ id: 'root',
58
+ component: 'Column',
59
+ gap: '16px',
60
+ padding: '24px',
61
+ children: ['title', 'content']
62
+ },
63
+ {
64
+ id: 'title',
65
+ component: 'Text',
66
+ text: 'Welcome!',
67
+ variant: 'h1'
68
+ },
69
+ {
70
+ id: 'content',
71
+ component: 'Text',
72
+ text: 'This UI was generated by an AI agent.'
73
+ }
74
+ ]
75
+ }
76
+ });
77
+ });
78
+
79
+ app.listen(3000, () => {
80
+ console.log('Server running at http://localhost:3000');
81
+ });
82
+ ```
83
+
84
+ ## Basic Client Setup
85
+
86
+ Create an HTML page that connects to the SSE stream:
87
+
88
+ ```html
89
+ <!DOCTYPE html>
90
+ <html>
91
+ <head>
92
+ <title>My Freesail App</title>
93
+ </head>
94
+ <body>
95
+ <div id="app"></div>
96
+
97
+ <script type="module">
98
+ import { FreesailClient } from '@freesail/core';
99
+ import { registerStandardCatalog, createSurfaceRenderer } from '@freesail/lit-ui';
100
+
101
+ // Register the standard component catalog
102
+ registerStandardCatalog();
103
+
104
+ // Create client and connect
105
+ const client = new FreesailClient({
106
+ url: '/api/stream',
107
+ debug: true
108
+ });
109
+
110
+ // Listen for surface creation
111
+ client.getProtocol().on('createSurface', ({ surfaceId, catalogId }) => {
112
+ // Create a renderer for this surface
113
+ createSurfaceRenderer({
114
+ container: document.getElementById('app'),
115
+ surfaceId,
116
+ store: client.getStore(),
117
+ debug: true
118
+ });
119
+ });
120
+
121
+ // Connect to server
122
+ await client.connect();
123
+ </script>
124
+ </body>
125
+ </html>
126
+ ```
127
+
128
+ ## Understanding the A2UX Protocol
129
+
130
+ ### Surface Lifecycle
131
+
132
+ 1. **Create Surface**: Agent initializes a UI container
133
+ 2. **Update Components**: Agent streams component definitions
134
+ 3. **Update Data**: Agent pushes data changes
135
+ 4. **User Action**: Client reports user interactions
136
+ 5. **Delete Surface**: Agent removes the UI
137
+
138
+ ### Component Structure
139
+
140
+ Components are defined as a flat list with parent-child relationships:
141
+
142
+ ```json
143
+ {
144
+ "updateComponents": {
145
+ "surfaceId": "main",
146
+ "components": [
147
+ { "id": "root", "component": "Column", "children": ["child1", "child2"] },
148
+ { "id": "child1", "component": "Text", "text": "Hello" },
149
+ { "id": "child2", "component": "Button", "label": "Click me", "action": "button_click" }
150
+ ]
151
+ }
152
+ }
153
+ ```
154
+
155
+ ### Data Model
156
+
157
+ Separate UI structure from data using `updateDataModel`:
158
+
159
+ ```json
160
+ {
161
+ "updateDataModel": {
162
+ "surfaceId": "main",
163
+ "path": "/user",
164
+ "value": { "name": "John", "email": "john@example.com" }
165
+ }
166
+ }
167
+ ```
168
+
169
+ Bind components to data using `$data:` prefix:
170
+
171
+ ```json
172
+ { "id": "greeting", "component": "Text", "text": "$data:/user/name" }
173
+ ```
174
+
175
+ ## Handling User Actions
176
+
177
+ When users interact with the UI, the client sends `userAction` messages:
178
+
179
+ ```typescript
180
+ // Server-side action handler
181
+ app.post('/api/action', express.json(), (req, res) => {
182
+ const { userAction } = req.body;
183
+
184
+ console.log('Action:', userAction.action);
185
+ console.log('Context:', userAction.context);
186
+
187
+ // Process action and update UI...
188
+
189
+ res.json({ success: true });
190
+ });
191
+ ```
192
+
193
+ The client automatically includes the full data model state in the `context` field.
194
+
195
+ ## Using with LLMs
196
+
197
+ ### OpenAI Integration
198
+
199
+ ```typescript
200
+ import OpenAI from 'openai';
201
+ import { createOpenAIAdapter, CatalogToToolConverter } from '@freesail/server';
202
+
203
+ const openai = new OpenAI();
204
+ const converter = new CatalogToToolConverter();
205
+ converter.addCatalog(standardCatalog);
206
+
207
+ app.get('/api/stream', (req, res) => {
208
+ const stream = new FreesailStream({ response: res });
209
+ const adapter = createOpenAIAdapter({ stream, catalogId, converter });
210
+
211
+ // Chat with OpenAI using UI tools
212
+ const response = await openai.chat.completions.create({
213
+ model: 'gpt-4',
214
+ messages: [
215
+ { role: 'system', content: converter.generateSystemPromptSection(catalogId) },
216
+ { role: 'user', content: 'Show me a welcome form' }
217
+ ],
218
+ tools: adapter.getTools()
219
+ });
220
+
221
+ // Process tool calls to render UI
222
+ processOpenAIResponse(response, adapter);
223
+ });
224
+ ```
225
+
226
+ ## Next Steps
227
+
228
+ - [Architecture Guide](./Architecture.md) - Deep dive into system design
229
+ - [Catalog Reference](./CatalogReference.md) - Standard component documentation
230
+ - [A2UX Protocol](./A2UX_Protocol.md) - Complete protocol specification
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@freesail/demo",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Freesail Demo - Example application showcasing the SDK",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "node server.js",
9
+ "build": "echo 'Demo does not require build'",
10
+ "start": "node server.js"
11
+ },
12
+ "dependencies": {
13
+ "@freesail/core": "^0.1.0",
14
+ "@freesail/server": "^0.1.0",
15
+ "express": "^4.18.2"
16
+ },
17
+ "devDependencies": {
18
+ "@types/express": "^4.17.21",
19
+ "@types/node": "^20.10.0"
20
+ }
21
+ }
@@ -0,0 +1,381 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Freesail Demo</title>
7
+ <style>
8
+ * {
9
+ box-sizing: border-box;
10
+ }
11
+
12
+ body {
13
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
14
+ margin: 0;
15
+ padding: 0;
16
+ background: #f5f5f5;
17
+ min-height: 100vh;
18
+ }
19
+
20
+ .app-container {
21
+ max-width: 800px;
22
+ margin: 0 auto;
23
+ padding: 20px;
24
+ }
25
+
26
+ .status-bar {
27
+ display: flex;
28
+ align-items: center;
29
+ gap: 8px;
30
+ padding: 12px 16px;
31
+ background: white;
32
+ border-radius: 8px;
33
+ margin-bottom: 20px;
34
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
35
+ }
36
+
37
+ .status-dot {
38
+ width: 10px;
39
+ height: 10px;
40
+ border-radius: 50%;
41
+ background: #ccc;
42
+ }
43
+
44
+ .status-dot.connected {
45
+ background: #22c55e;
46
+ }
47
+
48
+ .status-dot.connecting {
49
+ background: #eab308;
50
+ animation: pulse 1s infinite;
51
+ }
52
+
53
+ @keyframes pulse {
54
+ 0%, 100% { opacity: 1; }
55
+ 50% { opacity: 0.5; }
56
+ }
57
+
58
+ #surface-container {
59
+ background: white;
60
+ border-radius: 12px;
61
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
62
+ min-height: 400px;
63
+ }
64
+
65
+ .loading {
66
+ display: flex;
67
+ align-items: center;
68
+ justify-content: center;
69
+ height: 400px;
70
+ color: #666;
71
+ }
72
+
73
+ .error-message {
74
+ padding: 20px;
75
+ background: #fee2e2;
76
+ border-radius: 8px;
77
+ color: #b91c1c;
78
+ }
79
+ </style>
80
+ </head>
81
+ <body>
82
+ <div class="app-container">
83
+ <div class="status-bar">
84
+ <div class="status-dot" id="status-dot"></div>
85
+ <span id="status-text">Connecting...</span>
86
+ </div>
87
+
88
+ <div id="surface-container">
89
+ <div class="loading">
90
+ <span>Loading UI...</span>
91
+ </div>
92
+ </div>
93
+ </div>
94
+
95
+ <!-- Import Lit for Web Components -->
96
+ <script type="module">
97
+ import { html, render } from 'https://cdn.jsdelivr.net/npm/lit@3/+esm';
98
+
99
+ // Simple A2UX Client Implementation (minimal version)
100
+ class FreesailClient {
101
+ constructor(options) {
102
+ this.url = options.url;
103
+ this.container = options.container;
104
+ this.surfaces = new Map();
105
+ this.components = new Map();
106
+ this.dataModels = new Map();
107
+ this.eventSource = null;
108
+
109
+ this.onStatusChange = options.onStatusChange || (() => {});
110
+ }
111
+
112
+ connect() {
113
+ this.onStatusChange('connecting');
114
+
115
+ this.eventSource = new EventSource(this.url);
116
+
117
+ this.eventSource.onopen = () => {
118
+ this.onStatusChange('connected');
119
+ };
120
+
121
+ this.eventSource.onmessage = (event) => {
122
+ if (event.data === '[DONE]') {
123
+ return;
124
+ }
125
+
126
+ try {
127
+ const message = JSON.parse(event.data);
128
+ this.processMessage(message);
129
+ } catch (e) {
130
+ console.error('Failed to parse message:', e);
131
+ }
132
+ };
133
+
134
+ this.eventSource.onerror = () => {
135
+ this.onStatusChange('disconnected');
136
+ };
137
+ }
138
+
139
+ processMessage(message) {
140
+ if (message.createSurface) {
141
+ this.handleCreateSurface(message.createSurface);
142
+ } else if (message.updateComponents) {
143
+ this.handleUpdateComponents(message.updateComponents);
144
+ } else if (message.updateDataModel) {
145
+ this.handleUpdateDataModel(message.updateDataModel);
146
+ } else if (message.deleteSurface) {
147
+ this.handleDeleteSurface(message.deleteSurface);
148
+ }
149
+ }
150
+
151
+ handleCreateSurface({ surfaceId, catalogId }) {
152
+ console.log('Creating surface:', surfaceId, catalogId);
153
+ this.surfaces.set(surfaceId, { catalogId, components: new Map() });
154
+ this.dataModels.set(surfaceId, {});
155
+ }
156
+
157
+ handleUpdateComponents({ surfaceId, components }) {
158
+ console.log('Updating components:', surfaceId, components);
159
+
160
+ const surface = this.surfaces.get(surfaceId);
161
+ if (!surface) {
162
+ console.warn('Surface not found:', surfaceId);
163
+ return;
164
+ }
165
+
166
+ // Store components
167
+ for (const component of components) {
168
+ surface.components.set(component.id, component);
169
+ }
170
+
171
+ // Render
172
+ this.render(surfaceId);
173
+ }
174
+
175
+ handleUpdateDataModel({ surfaceId, path, op, value }) {
176
+ console.log('Updating data model:', surfaceId, path, value);
177
+
178
+ if (path === '/' || !path) {
179
+ this.dataModels.set(surfaceId, value);
180
+ } else {
181
+ const model = this.dataModels.get(surfaceId) || {};
182
+ this.setByPath(model, path, value);
183
+ this.dataModels.set(surfaceId, model);
184
+ }
185
+ }
186
+
187
+ handleDeleteSurface({ surfaceId }) {
188
+ this.surfaces.delete(surfaceId);
189
+ this.dataModels.delete(surfaceId);
190
+ this.container.innerHTML = '';
191
+ }
192
+
193
+ setByPath(obj, path, value) {
194
+ const keys = path.split('/').filter(Boolean);
195
+ let current = obj;
196
+
197
+ for (let i = 0; i < keys.length - 1; i++) {
198
+ if (!(keys[i] in current)) {
199
+ current[keys[i]] = {};
200
+ }
201
+ current = current[keys[i]];
202
+ }
203
+
204
+ if (keys.length > 0) {
205
+ current[keys[keys.length - 1]] = value;
206
+ }
207
+ }
208
+
209
+ render(surfaceId) {
210
+ const surface = this.surfaces.get(surfaceId);
211
+ if (!surface) return;
212
+
213
+ const root = surface.components.get('root');
214
+ if (!root) return;
215
+
216
+ const html = this.renderComponent(root, surface.components);
217
+ this.container.innerHTML = html;
218
+
219
+ // Attach event listeners
220
+ this.attachEventListeners(surfaceId);
221
+ }
222
+
223
+ renderComponent(component, components) {
224
+ const { id, component: type, children = [], ...props } = component;
225
+
226
+ let childrenHtml = '';
227
+ if (children.length > 0) {
228
+ childrenHtml = children
229
+ .map(childId => {
230
+ const child = components.get(childId);
231
+ return child ? this.renderComponent(child, components) : '';
232
+ })
233
+ .join('');
234
+ }
235
+
236
+ // Map component types to HTML (simplified renderer)
237
+ switch (type) {
238
+ case 'Column':
239
+ return `<div id="${id}" style="display:flex;flex-direction:column;gap:${props.gap || '8px'};padding:${props.padding || '0'};align-items:${this.mapAlign(props.align)};justify-content:${this.mapJustify(props.justify)}">${childrenHtml}</div>`;
240
+
241
+ case 'Row':
242
+ return `<div id="${id}" style="display:flex;flex-direction:row;gap:${props.gap || '8px'};padding:${props.padding || '0'};align-items:${this.mapAlign(props.align)};justify-content:${this.mapJustify(props.justify)}">${childrenHtml}</div>`;
243
+
244
+ case 'Text':
245
+ const textStyles = `color:${props.color || 'inherit'};text-align:${props.align || 'left'}`;
246
+ switch (props.variant) {
247
+ case 'h1': return `<h1 id="${id}" style="${textStyles}">${props.text}</h1>`;
248
+ case 'h2': return `<h2 id="${id}" style="${textStyles}">${props.text}</h2>`;
249
+ case 'h3': return `<h3 id="${id}" style="${textStyles}">${props.text}</h3>`;
250
+ default: return `<p id="${id}" style="${textStyles}">${props.text}</p>`;
251
+ }
252
+
253
+ case 'Button':
254
+ const btnStyle = this.getButtonStyle(props.variant);
255
+ return `<button id="${id}" data-action="${props.action || ''}" style="${btnStyle}" ${props.disabled ? 'disabled' : ''}>${props.label}</button>`;
256
+
257
+ case 'Input':
258
+ return `
259
+ <div id="${id}" style="display:flex;flex-direction:column;gap:4px">
260
+ ${props.label ? `<label style="font-size:0.875rem;font-weight:500;color:#374151">${props.label}</label>` : ''}
261
+ <input type="${props.type || 'text'}" placeholder="${props.placeholder || ''}" data-bind="${props.bindPath || ''}" style="padding:10px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:1rem" />
262
+ </div>
263
+ `;
264
+
265
+ case 'Card':
266
+ return `
267
+ <div id="${id}" style="background:white;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.1);overflow:hidden">
268
+ ${props.title ? `<div style="padding:16px;border-bottom:1px solid #e5e7eb"><h3 style="margin:0;font-size:1.125rem">${props.title}</h3>${props.subtitle ? `<p style="margin:4px 0 0;color:#6b7280;font-size:0.875rem">${props.subtitle}</p>` : ''}</div>` : ''}
269
+ <div style="padding:${props.padding || '16px'}">${childrenHtml}</div>
270
+ </div>
271
+ `;
272
+
273
+ case 'Badge':
274
+ return `<span id="${id}" style="${this.getBadgeStyle(props.variant)}">${props.text}</span>`;
275
+
276
+ case 'Checkbox':
277
+ return `
278
+ <label id="${id}" style="display:flex;align-items:center;gap:8px;cursor:pointer">
279
+ <input type="checkbox" data-bind="${props.bindPath || ''}" ${props.checked ? 'checked' : ''} style="width:18px;height:18px" />
280
+ <span>${props.label || ''}</span>
281
+ </label>
282
+ `;
283
+
284
+ default:
285
+ return `<div id="${id}">${childrenHtml}</div>`;
286
+ }
287
+ }
288
+
289
+ mapAlign(align) {
290
+ const map = { start: 'flex-start', center: 'center', end: 'flex-end', stretch: 'stretch' };
291
+ return map[align] || 'stretch';
292
+ }
293
+
294
+ mapJustify(justify) {
295
+ const map = { start: 'flex-start', center: 'center', end: 'flex-end', between: 'space-between', around: 'space-around' };
296
+ return map[justify] || 'flex-start';
297
+ }
298
+
299
+ getButtonStyle(variant) {
300
+ const base = 'padding:10px 20px;border:none;border-radius:6px;font-size:1rem;font-weight:500;cursor:pointer;';
301
+ switch (variant) {
302
+ case 'primary': return base + 'background:#2563eb;color:white;';
303
+ case 'secondary': return base + 'background:#6b7280;color:white;';
304
+ case 'outline': return base + 'background:transparent;color:#2563eb;border:2px solid #2563eb;';
305
+ case 'ghost': return base + 'background:transparent;color:#374151;';
306
+ default: return base + 'background:#2563eb;color:white;';
307
+ }
308
+ }
309
+
310
+ getBadgeStyle(variant) {
311
+ const base = 'display:inline-flex;padding:4px 12px;border-radius:9999px;font-size:0.875rem;font-weight:500;';
312
+ switch (variant) {
313
+ case 'primary': return base + 'background:#dbeafe;color:#1d4ed8;';
314
+ case 'success': return base + 'background:#dcfce7;color:#15803d;';
315
+ case 'warning': return base + 'background:#fef3c7;color:#b45309;';
316
+ case 'error': return base + 'background:#fee2e2;color:#b91c1c;';
317
+ default: return base + 'background:#e5e7eb;color:#374151;';
318
+ }
319
+ }
320
+
321
+ attachEventListeners(surfaceId) {
322
+ // Button clicks
323
+ this.container.querySelectorAll('button[data-action]').forEach(btn => {
324
+ btn.addEventListener('click', () => {
325
+ const action = btn.dataset.action;
326
+ if (action) {
327
+ this.sendAction(surfaceId, action);
328
+ }
329
+ });
330
+ });
331
+
332
+ // Input changes
333
+ this.container.querySelectorAll('input[data-bind]').forEach(input => {
334
+ input.addEventListener('change', (e) => {
335
+ const path = input.dataset.bind;
336
+ if (path) {
337
+ const value = input.type === 'checkbox' ? input.checked : input.value;
338
+ const model = this.dataModels.get(surfaceId) || {};
339
+ this.setByPath(model, path, value);
340
+ }
341
+ });
342
+ });
343
+ }
344
+
345
+ sendAction(surfaceId, action) {
346
+ const context = this.dataModels.get(surfaceId) || {};
347
+
348
+ fetch('/api/action', {
349
+ method: 'POST',
350
+ headers: { 'Content-Type': 'application/json' },
351
+ body: JSON.stringify({
352
+ userAction: { surfaceId, action, context }
353
+ })
354
+ });
355
+ }
356
+
357
+ disconnect() {
358
+ if (this.eventSource) {
359
+ this.eventSource.close();
360
+ this.eventSource = null;
361
+ }
362
+ }
363
+ }
364
+
365
+ // Initialize client
366
+ const statusDot = document.getElementById('status-dot');
367
+ const statusText = document.getElementById('status-text');
368
+
369
+ const client = new FreesailClient({
370
+ url: '/api/stream',
371
+ container: document.getElementById('surface-container'),
372
+ onStatusChange: (status) => {
373
+ statusDot.className = 'status-dot ' + status;
374
+ statusText.textContent = status.charAt(0).toUpperCase() + status.slice(1);
375
+ }
376
+ });
377
+
378
+ client.connect();
379
+ </script>
380
+ </body>
381
+ </html>