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.
- package/README.md +190 -5
- package/docs/A2UX_Protocol.md +183 -0
- package/docs/Agents.md +218 -0
- package/docs/Architecture.md +285 -0
- package/docs/CatalogReference.md +377 -0
- package/docs/GettingStarted.md +230 -0
- package/examples/demo/package.json +21 -0
- package/examples/demo/public/index.html +381 -0
- package/examples/demo/server.js +253 -0
- package/package.json +38 -5
- package/packages/core/package.json +48 -0
- package/packages/core/src/functions.ts +403 -0
- package/packages/core/src/index.ts +214 -0
- package/packages/core/src/parser.ts +270 -0
- package/packages/core/src/protocol.ts +254 -0
- package/packages/core/src/store.ts +452 -0
- package/packages/core/src/transport.ts +439 -0
- package/packages/core/src/types.ts +209 -0
- package/packages/core/tsconfig.json +10 -0
- package/packages/lit-ui/package.json +44 -0
- package/packages/lit-ui/src/catalogs/standard/catalog.json +405 -0
- package/packages/lit-ui/src/catalogs/standard/elements/Badge.ts +96 -0
- package/packages/lit-ui/src/catalogs/standard/elements/Button.ts +147 -0
- package/packages/lit-ui/src/catalogs/standard/elements/Card.ts +78 -0
- package/packages/lit-ui/src/catalogs/standard/elements/Checkbox.ts +94 -0
- package/packages/lit-ui/src/catalogs/standard/elements/Column.ts +66 -0
- package/packages/lit-ui/src/catalogs/standard/elements/Divider.ts +59 -0
- package/packages/lit-ui/src/catalogs/standard/elements/Image.ts +54 -0
- package/packages/lit-ui/src/catalogs/standard/elements/Input.ts +125 -0
- package/packages/lit-ui/src/catalogs/standard/elements/Progress.ts +79 -0
- package/packages/lit-ui/src/catalogs/standard/elements/Row.ts +68 -0
- package/packages/lit-ui/src/catalogs/standard/elements/Select.ts +110 -0
- package/packages/lit-ui/src/catalogs/standard/elements/Spacer.ts +37 -0
- package/packages/lit-ui/src/catalogs/standard/elements/Spinner.ts +76 -0
- package/packages/lit-ui/src/catalogs/standard/elements/Text.ts +86 -0
- package/packages/lit-ui/src/catalogs/standard/elements/index.ts +18 -0
- package/packages/lit-ui/src/catalogs/standard/index.ts +17 -0
- package/packages/lit-ui/src/index.ts +84 -0
- package/packages/lit-ui/src/renderer.ts +211 -0
- package/packages/lit-ui/src/types.ts +49 -0
- package/packages/lit-ui/src/utils/define-props.ts +157 -0
- package/packages/lit-ui/src/utils/index.ts +2 -0
- package/packages/lit-ui/src/utils/registry.ts +139 -0
- package/packages/lit-ui/tsconfig.json +11 -0
- package/packages/server/package.json +61 -0
- package/packages/server/src/adapters/index.ts +5 -0
- package/packages/server/src/adapters/langchain.ts +175 -0
- package/packages/server/src/adapters/openai.ts +209 -0
- package/packages/server/src/catalog-loader.ts +311 -0
- package/packages/server/src/index.ts +142 -0
- package/packages/server/src/stream.ts +329 -0
- package/packages/server/tsconfig.json +11 -0
- package/tsconfig.base.json +23 -0
- 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>
|