@surph_ai/cli 0.0.28 → 0.0.32
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/cli.js +4 -1
- package/package.json +1 -1
- package/scaffold/board/README.md +422 -0
- package/scaffold/board/assets/css/site.css +224 -0
- package/scaffold/board/board.json +22 -0
- package/scaffold/board/index.html +152 -0
- package/scaffold/board/package.json +15 -0
- package/scaffold/index.js +133 -2
- package/scaffold/project/package.json +9 -15
package/cli.js
CHANGED
|
@@ -293,6 +293,10 @@ program.command('tool <name>')
|
|
|
293
293
|
.description('create new Surph tool')
|
|
294
294
|
.action(name => scaffold(name, 'tool'))
|
|
295
295
|
|
|
296
|
+
program.command('board <name>')
|
|
297
|
+
.description('create new Surph board')
|
|
298
|
+
.action(name => scaffold(name, 'board'))
|
|
299
|
+
|
|
296
300
|
program.command('manifest')
|
|
297
301
|
.description('update manifest for Surph tool')
|
|
298
302
|
.action(async (cmd, options) => {
|
|
@@ -537,7 +541,6 @@ program.command('publish')
|
|
|
537
541
|
}
|
|
538
542
|
})
|
|
539
543
|
|
|
540
|
-
|
|
541
544
|
program.command('deploy')
|
|
542
545
|
.description('Deploy to Surph')
|
|
543
546
|
.action(async (cmd, options) => {
|
package/package.json
CHANGED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# Surph board
|
|
2
|
+
|
|
3
|
+
This project is a **Surph board**: a set of web pages that Surph shows in its **canvas**, the panel beside the Surph chat. Each page is a static HTML file. Surph loads it in an iframe and answers the page's requests for data. The page talks to Surph only through the Surph SDK (`surph-wave.js`), which works over `postMessage`.
|
|
4
|
+
|
|
5
|
+
**If you're a coding agent, read this whole file before writing a page.** Surph answers only the operations listed in [Operations the host answers](#operations-the-host-answers), with exactly the payloads shown. A page that calls anything else gets an error, and a field not listed here doesn't exist.
|
|
6
|
+
|
|
7
|
+
## How a page runs in Surph
|
|
8
|
+
|
|
9
|
+
- **Surph is the host; the page is the guest.** Surph frames the page and answers its `host.rpc(op, args)` calls with data it already has: the board's recent items, the chat's context, the items the chat looked up, the signed-in person's contexts. The page never calls Surph's servers itself, and it can't read Surph's cookies, session or DOM.
|
|
10
|
+
- **The chat decides which page to show.** Every page is registered with a `shows` line: plain facts about what it displays. The chat's model reads the board's pages and their `shows` lines and opens the page that fits what the person asked. A person can also pick a page from the canvas's Pages menu.
|
|
11
|
+
- **The chat can hand a page values.** A page can declare address parameters (for example `?ids=`). The chat's model sees them and opens the page at an address that carries them; the page reads them from `location.search`.
|
|
12
|
+
- **Pages update as the chat goes.** When the data behind an operation changes (the chat looked up new items, the person subscribed to a context), Surph calls the page's `onRefresh`, and the page asks again and repaints.
|
|
13
|
+
- **A board's pages replace Surph's own.** A chat on your board shows only your board's pages. One of them is the **start page** the canvas opens on.
|
|
14
|
+
- **Opened directly, a page still loads.** Outside Surph, `host.hosted` is `false` and every call rejects. The page must still render its empty layout without errors. That's what you see when you run this project locally.
|
|
15
|
+
|
|
16
|
+
## Project layout
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
index.html The board's home page (edit it; it starts as an intro to this setup)
|
|
20
|
+
<page>.html Each further page sits next to it (items.html, events.html, …)
|
|
21
|
+
assets/
|
|
22
|
+
css/site.css Styles shared by every page in the board
|
|
23
|
+
js/ Scripts shared by the board's pages (optional)
|
|
24
|
+
board.json The board itself: name, contexts, instructions, and its pages (see "Registering pages")
|
|
25
|
+
package.json `npm run dev` serves this folder at http://localhost:4180
|
|
26
|
+
README.md This file
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- **Pages sit at the top of the folder.** A page's registry entry names its file without a folder, so a page can't live in a subfolder.
|
|
30
|
+
- **Everything else a page loads, apart from the SDK, goes under `assets/`**, linked with relative paths (`assets/css/site.css`, `./items.html`).
|
|
31
|
+
|
|
32
|
+
### Shared styles: `assets/css/site.css`
|
|
33
|
+
|
|
34
|
+
Every page links it in `<head>`, before its own `<style>`:
|
|
35
|
+
|
|
36
|
+
```html
|
|
37
|
+
<link rel="stylesheet" href="assets/css/site.css?v=1">
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- It holds the colour variables (`--ink`, `--muted`, `--faint`, `--subtle`, `--accent`, `--rule`, `--tint`, …), the page shell (body, `h1`, `h2`, `p.empty`) and a few shared pieces (a context list `ul.contexts` of `.context` rows with an `.icon`, and an owner pill `button.owner`). Use the variables in page styles rather than repeating colours.
|
|
41
|
+
- A page's own `<style>` holds only what that page alone uses.
|
|
42
|
+
- **A change to `site.css` changes every page.** Check each page that uses what you changed.
|
|
43
|
+
- **Bump `?v=` in every page's link** whenever `site.css` changes, so browsers don't keep serving the old copy.
|
|
44
|
+
|
|
45
|
+
## Registering pages
|
|
46
|
+
|
|
47
|
+
Surph only shows pages that are registered with the board. The board itself is described by **`board.json`** in this folder, shaped like the board's record in Surph; its pages are `config.pages`, one entry per page. Keep it in step with the pages, because it's what Surph reads:
|
|
48
|
+
- **While you develop**, Surph's dev mode runs as the board this file describes, read from your local server (see [Developing locally](#developing-locally)). No live board is needed.
|
|
49
|
+
- **When the board goes live**, Surph sets up the board's record from this same file; today Surph does that step, so your hand-off includes `board.json`.
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"slug": "my-board",
|
|
54
|
+
"name": "My Board",
|
|
55
|
+
"description": "What the board is about.",
|
|
56
|
+
"logo": "logo.png",
|
|
57
|
+
"contexts": ["<context slug>"],
|
|
58
|
+
"defaultContext": "<context slug>",
|
|
59
|
+
"waves": [],
|
|
60
|
+
"tools": [],
|
|
61
|
+
"config": {
|
|
62
|
+
"instructions": "How the board's chat should behave: its tone, what it helps with.",
|
|
63
|
+
"pages": [
|
|
64
|
+
{ "name": "home", "file": "index.html", "shows": "…", "start": "always", "ops": ["board.get"] }
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Field | Meaning |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| `slug` | The board's short id. |
|
|
73
|
+
| `name` | The board's name, shown in Surph's header and returned by `board.get`. |
|
|
74
|
+
| `description` | One line about the board. |
|
|
75
|
+
| `logo` | An image file in `assets/` (just its name, e.g. `logo.png`), shown in Surph's header. |
|
|
76
|
+
| `contexts` | Slugs of the Surph contexts the board draws on. Their items are what `board.get` and the chat's lookups return. They're live Surph contexts; find a context's slug in its address (`/c/<slug>`). |
|
|
77
|
+
| `defaultContext` | The context the chat starts with; else the first in `contexts`. |
|
|
78
|
+
| `waves`, `tools` | Slugs of Surph waves and tools on the board. Optional. |
|
|
79
|
+
| `config.instructions` | Instructions from the board's owner for the board's chat: tone, behavior, what it helps with. On a board they take the place of each context's own instructions. |
|
|
80
|
+
| `config.pages` | The board's pages, one registry entry each (below). |
|
|
81
|
+
|
|
82
|
+
A registry entry:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"name": "events",
|
|
87
|
+
"file": "events.html",
|
|
88
|
+
"shows": "Upcoming events from the board's contexts: each event's name, date, place, and a link to its source.",
|
|
89
|
+
"ops": ["board.get"]
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
| Field | Meaning |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `name` | Short, stable label. The chat's model and saved conversations refer to the page by this name, and the canvas header shows it, so don't rename it casually. Unique within the board. |
|
|
96
|
+
| `file` | The HTML file's name only, with no folder (`events.html`). It's found next to `board.json`: on your local server while you develop, and wherever the board's files are hosted when it's live. |
|
|
97
|
+
| `url` | Instead of `file`, a page's full `https://` address when it lives somewhere else. |
|
|
98
|
+
| `shows` | What the page displays, as plain facts. The chat's model reads this to judge when the page fits. Describe the content, not instructions: write "Every item in the board's newest 25, …", not "Use this when the user asks about…". |
|
|
99
|
+
| `start` | Optional. Marks a page the canvas opens on before the chat starts: `"always"`, `"context"` (a chat that has a context) or `"no-context"` (a chat without one). A board needs at least one. A start page may route on load to another page with [`page.open`](#pageopen) and `replace: true`. |
|
|
100
|
+
| `ops` | The operations the page calls. It must match the `host.rpc(…)` calls in the file. Surph finds pages by op; for example, the page that calls `items.get` is used to show the items a chat looked up. |
|
|
101
|
+
| `params` | Optional. Address parameters the page reads, as `{ "<name>": "<what it is>" }`. The chat's model sees them and can open the page with them. |
|
|
102
|
+
|
|
103
|
+
## The SDK
|
|
104
|
+
|
|
105
|
+
Every page loads the SDK from this exact URL. Don't copy, bundle, edit or replace it.
|
|
106
|
+
|
|
107
|
+
```html
|
|
108
|
+
<script src="https://surph.s3.us-east-1.amazonaws.com/admin/desktop/js/wave-sdk/v1/surph-wave.js"></script>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
It defines one global, `SurphWave`:
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
const host = SurphWave.connect({
|
|
115
|
+
pageSlug: 'events', // The page's own name (any short string)
|
|
116
|
+
onRefresh: function () { paint(); } // Surph's data changed: ask again and repaint
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
host.ready(); // Call once, after connect
|
|
120
|
+
host.rpc(op, args, { timeout }); // Ask Surph for something; returns a Promise
|
|
121
|
+
host.hosted; // true inside the Surph canvas, false when opened directly
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- `rpc(op, args, extra)` sends `args` as an object (use `{}` when there are none). It resolves with the operation's result object.
|
|
125
|
+
- It rejects with:
|
|
126
|
+
- `Error('host timeout')`: no reply in time. The default wait is 15 seconds; pass `{ timeout: 800 }` on reads.
|
|
127
|
+
- `Error('not in a Surph host')`: the page isn't inside the canvas (`hosted` is false).
|
|
128
|
+
- `Error('<message>')`: Surph answered with an error, for example `Unknown op: …` for an operation it doesn't support.
|
|
129
|
+
- Only use `connect`, `ready`, `rpc`, `hosted` and the `onRefresh` option. The script has other options; they belong to other hosts and do nothing here.
|
|
130
|
+
|
|
131
|
+
## Call pattern
|
|
132
|
+
|
|
133
|
+
Every page that reads data uses this loop. Keep its rules:
|
|
134
|
+
- a **timeout** means Surph is still loading, so ask again shortly;
|
|
135
|
+
- any **other error** means stop;
|
|
136
|
+
- paint only after a successful reply.
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
const host = SurphWave.connect({
|
|
140
|
+
pageSlug: 'events',
|
|
141
|
+
onRefresh: function () { paint(); }
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
let painting = false;
|
|
145
|
+
async function paint() {
|
|
146
|
+
if (painting) return;
|
|
147
|
+
painting = true;
|
|
148
|
+
let got;
|
|
149
|
+
try {
|
|
150
|
+
got = await host.rpc('board.get', {}, { timeout: 800 });
|
|
151
|
+
} catch (e) {
|
|
152
|
+
painting = false;
|
|
153
|
+
if (e && e.message === 'host timeout' && host.hosted) setTimeout(paint, 200);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
painting = false;
|
|
157
|
+
// got is the operation's result. Paint it here (see "Rules for pages").
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
host.ready();
|
|
161
|
+
paint();
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Surph holds a read until its data is ready rather than answering with an error, so a short timeout plus a retry is expected while it loads. Don't clear the page before a reply arrives: if a refresh fails, the page keeps what it last painted.
|
|
165
|
+
|
|
166
|
+
## Operations the host answers
|
|
167
|
+
|
|
168
|
+
These are the only operations Surph answers. Every field below is exactly what arrives; treat anything not listed as absent.
|
|
169
|
+
|
|
170
|
+
### `board.get`
|
|
171
|
+
|
|
172
|
+
No args. Resolves once the board's recent items have loaded.
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
{
|
|
176
|
+
name: "University of Maryland",
|
|
177
|
+
items: [ /* Item, … */ ]
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- `name`: the board's name; `""` when the chat isn't on a board.
|
|
182
|
+
- `items`: the 25 newest visible items across every context on the board, newest first (by `createdAt`, else `updatedAt`), each an [Item](#item). `[]` when there are none.
|
|
183
|
+
|
|
184
|
+
### `items.get`
|
|
185
|
+
|
|
186
|
+
The items the chat has looked up. Two forms:
|
|
187
|
+
|
|
188
|
+
- **No args (`{}`).** Resolves once the chat has looked items up; until then the call is held. The items are the chat's current list, which changes as the conversation goes on; when it does, Surph sends `onRefresh`. A lookup sets the list to what it returned; when the chat's reply finishes, the list becomes the items the reply cited, in citation order, if it cited any.
|
|
189
|
+
- **`{ ids: ['<id>', …] }`.** Resolves at once with exactly those items, in that order (at most 50); never held. Ids that aren't made of letters, digits, `_` or `-` are dropped, and an id with no visible item is left out.
|
|
190
|
+
|
|
191
|
+
```js
|
|
192
|
+
{
|
|
193
|
+
title: "",
|
|
194
|
+
description: "",
|
|
195
|
+
items: [ /* Item, … */ ]
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
- `title` and `description` are strings, currently always `""`. Hide them when they're empty.
|
|
200
|
+
- `items`: the chat's current items (or the requested `ids`), each an [Item](#item), in that list's order.
|
|
201
|
+
|
|
202
|
+
A page that takes `?ids=a,b,c` passes them on, and its registry entry declares the parameter so the chat's model can open it with a chosen set:
|
|
203
|
+
|
|
204
|
+
```js
|
|
205
|
+
const raw = new URLSearchParams(location.search).get('ids') || '';
|
|
206
|
+
const ids = raw.split(',').map(function (id) { return id.trim(); }).filter(Boolean);
|
|
207
|
+
got = await host.rpc('items.get', ids.length ? { ids: ids } : {}, { timeout: 800 });
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
```json
|
|
211
|
+
"params": { "ids": "Comma-separated item ids (the <id> of [[ref:<id>]]). The page shows exactly those items, in that order." }
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### `context.get`
|
|
215
|
+
|
|
216
|
+
The chat's context. No args. Resolves once the chat has one.
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
{
|
|
220
|
+
slug: "university-of-maryland-ufiz9v9e",
|
|
221
|
+
name: "University of Maryland",
|
|
222
|
+
topics: [
|
|
223
|
+
{ name: "Events", description: "What's happening on campus." }
|
|
224
|
+
],
|
|
225
|
+
subscribed: false,
|
|
226
|
+
isCreator: false
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
- `topics` only includes topics that have a name. `description` is `""` when a topic has none.
|
|
231
|
+
- `subscribed`: `true` when the signed-in person subscribes to the context. When that changes, Surph sends `onRefresh`.
|
|
232
|
+
- `isCreator`: `true` when the signed-in person created the context. It's `false` until Surph has loaded the context's record; Surph sends `onRefresh` when it changes.
|
|
233
|
+
|
|
234
|
+
### `context.subscribe`
|
|
235
|
+
|
|
236
|
+
Subscribes the signed-in person to the chat's context. `{ subscribed: false }` unsubscribes.
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
await host.rpc('context.subscribe', { subscribed: true });
|
|
240
|
+
// resolves { subscribed: true }
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
- Resolves with the new state once Surph has saved it.
|
|
244
|
+
- Rejects with `The chat has no context`, `Sign in to subscribe` (no signed-in person), or `Could not subscribe` / `Could not unsubscribe` (the save failed and nothing changed). Keep the old state on a rejection.
|
|
245
|
+
- Call it only from the person's own click, and disable the button while the call is out so a double click doesn't toggle twice.
|
|
246
|
+
|
|
247
|
+
### `contexts.get`
|
|
248
|
+
|
|
249
|
+
The contexts the signed-in person subscribes to. No args. Resolves at once; never held.
|
|
250
|
+
|
|
251
|
+
```js
|
|
252
|
+
{
|
|
253
|
+
contexts: [
|
|
254
|
+
{
|
|
255
|
+
slug: "recommendations-w1ey7i96",
|
|
256
|
+
name: "Recommendations",
|
|
257
|
+
description: "Tracks personal recommendations for products, food, …",
|
|
258
|
+
icon: "https://…/recommendations-icon.png",
|
|
259
|
+
creator: { username: "dev_kinney5521", avatar: "https://…" },
|
|
260
|
+
canEdit: true
|
|
261
|
+
}
|
|
262
|
+
]
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
- `contexts`: `[]` when the person has none or isn't signed in.
|
|
267
|
+
- `name`: the context's name, else its slug. `description`: `""` when there is none.
|
|
268
|
+
- `icon`: the full URL of the context's icon. Omitted when it has none.
|
|
269
|
+
- `creator`: the context's owner, `{ username, avatar? }`. `avatar` is omitted when there is none; `creator` is omitted when the owner isn't known. Avatars may come from another site, so set `referrerPolicy = 'no-referrer'` on the `<img>`.
|
|
270
|
+
- `canEdit`: `true` when the signed-in person created the context or collaborates on it. Show an edit control only when it's `true`.
|
|
271
|
+
- When the list changes, Surph sends `onRefresh`.
|
|
272
|
+
|
|
273
|
+
### `contexts.public`
|
|
274
|
+
|
|
275
|
+
Every public context on Surph (at most 100). No args. Surph fetches the list the first time a page asks and the call waits for it, so pass a longer timeout than 800ms (for example `{ timeout: 5000 }`).
|
|
276
|
+
|
|
277
|
+
```js
|
|
278
|
+
{ contexts: [ /* same shape as contexts.get */ ] }
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
- `contexts`: `[]` when there are none or the list couldn't be fetched; the next call fetches again.
|
|
282
|
+
|
|
283
|
+
### `profile.get`
|
|
284
|
+
|
|
285
|
+
On a person's profile in Surph: that profile and the contexts it created. No args. Held until a profile is open and its contexts have loaded.
|
|
286
|
+
|
|
287
|
+
```js
|
|
288
|
+
{
|
|
289
|
+
username: "dev_kinney5521",
|
|
290
|
+
contexts: [ /* same shape as contexts.get */ ]
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### `context.open`
|
|
295
|
+
|
|
296
|
+
Starts a chat with a context and moves the canvas to the page that calls `context.get`.
|
|
297
|
+
|
|
298
|
+
```js
|
|
299
|
+
await host.rpc('context.open', { slug: 'recommendations-w1ey7i96' });
|
|
300
|
+
// resolves { ok: true }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
- `slug` is required: a `slug` from `contexts.get`, `contexts.public` or `profile.get`. A missing one rejects with `slug is required`.
|
|
304
|
+
- The calling page is replaced, so don't count on running code after the call.
|
|
305
|
+
|
|
306
|
+
### `context.edit`
|
|
307
|
+
|
|
308
|
+
Opens a context in Surph's context builder (the whole app moves there).
|
|
309
|
+
|
|
310
|
+
```js
|
|
311
|
+
await host.rpc('context.edit', { slug: 'recommendations-w1ey7i96' });
|
|
312
|
+
// resolves { ok: true }
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
- `slug` is required, from a context whose `canEdit` is `true`. Surph rejects any other with `You can't edit this context`.
|
|
316
|
+
|
|
317
|
+
### `profile.open`
|
|
318
|
+
|
|
319
|
+
Takes the person to someone's profile in Surph (the whole app moves there).
|
|
320
|
+
|
|
321
|
+
```js
|
|
322
|
+
await host.rpc('profile.open', { username: 'dev_kinney5521' });
|
|
323
|
+
// resolves { ok: true }
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
- `username` is required, for example a context's `creator.username`. A missing or malformed one rejects with `username is required`.
|
|
327
|
+
|
|
328
|
+
### `page.open`
|
|
329
|
+
|
|
330
|
+
Moves the canvas to another page of this board, by its registry `name`.
|
|
331
|
+
|
|
332
|
+
```js
|
|
333
|
+
await host.rpc('page.open', { name: 'events', replace: true });
|
|
334
|
+
// resolves { ok: true }
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
- `name` is required. A name the board doesn't have rejects with `Unknown page: <name>`; catch it and paint the calling page instead.
|
|
338
|
+
- The new page loads as if picked from the Pages menu: the header shows its name, the chat is told it's showing, and the conversation remembers it.
|
|
339
|
+
- `replace: true` puts the new page in the calling page's place, so Back skips the caller. Use it when a start page routes on load (otherwise Back returns to the start page, which routes again). Replacing a page with itself does nothing.
|
|
340
|
+
- The calling page is unloaded, so don't count on running code after the call.
|
|
341
|
+
|
|
342
|
+
### `chat.say`
|
|
343
|
+
|
|
344
|
+
Puts one short line into the chat, for a greeting or a short note. The page is what the canvas shows, so don't use this to repeat page content.
|
|
345
|
+
|
|
346
|
+
```js
|
|
347
|
+
await host.rpc('chat.say', { text: 'Welcome to the board.' });
|
|
348
|
+
// resolves { ok: true }
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
- `text` is required. Surph trims it and keeps the first 500 characters. An empty `text` rejects with `text is required`.
|
|
352
|
+
|
|
353
|
+
### Item
|
|
354
|
+
|
|
355
|
+
`board.get` and `items.get` return items in this one shape: a chosen set of public fields, not the stored record.
|
|
356
|
+
|
|
357
|
+
```js
|
|
358
|
+
{
|
|
359
|
+
id: "item_mvbf9necbickh1",
|
|
360
|
+
title: "Black Beans (30 oz, 6-Pack)",
|
|
361
|
+
preview: "My go-to pantry staple — great for tacos, salads, soups…",
|
|
362
|
+
content: "My go-to pantry staple — great for tacos, salads, soups, and batch cooking. …",
|
|
363
|
+
source: { type: "url", url: "https://www.amazon.com/dp/B00GXHTZCI" },
|
|
364
|
+
context: { slug: "recommendations-w1ey7i96", name: "Recommendations" },
|
|
365
|
+
topic: { id: "topic_mva2c2v8b698k4", name: "Food" },
|
|
366
|
+
addedBy: { username: "dev_kinney5521", avatar: "https://…" },
|
|
367
|
+
media: [{ type: "image", url: "https://…" }],
|
|
368
|
+
tags: ["pantry"],
|
|
369
|
+
createdAt: "2026-10-16T20:30:35.087Z",
|
|
370
|
+
updatedAt: "2026-10-19T15:24:42.587Z"
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
| Field | |
|
|
375
|
+
| --- | --- |
|
|
376
|
+
| `id` | The item's id (a string). |
|
|
377
|
+
| `title` | The item's title, else its URL, else `"Untitled"`. |
|
|
378
|
+
| `preview` | A short summary. `""` when the item has none. |
|
|
379
|
+
| `content` | The item's full text (it may contain line breaks and Markdown). `""` when it has none. A list page shortens it itself. |
|
|
380
|
+
| `source` | `{ type }` (`"url"` or `"text"`), plus `url` when the item came from an address. Omitted when unknown. |
|
|
381
|
+
| `context` | `{ slug, name }` of the context the item belongs to. Omitted when unknown. |
|
|
382
|
+
| `topic` | `{ id, name }` of the item's topic. Omitted when it has none. |
|
|
383
|
+
| `addedBy` | `{ username, avatar? }` of the person who added it. Omitted when unknown. |
|
|
384
|
+
| `media` | `[{ type, url }]`. `[]` when there is none. |
|
|
385
|
+
| `tags` | Strings. `[]` when there are none. |
|
|
386
|
+
| `createdAt` | When it was added, ISO 8601. Omitted when unknown. |
|
|
387
|
+
| `updatedAt` | When it was last changed, ISO 8601. Omitted when unknown. |
|
|
388
|
+
|
|
389
|
+
A field marked "omitted" is missing from the object, not `null`. Every URL is `http` or `https`.
|
|
390
|
+
|
|
391
|
+
## Rules for pages
|
|
392
|
+
|
|
393
|
+
- **Write data with `textContent`, never `innerHTML`.** Every string from Surph comes from what people submitted. Build elements with `document.createElement` and set `textContent`. Set `href` and `src` only to values that start with `http://` or `https://`.
|
|
394
|
+
- **Data comes through the operations above.** Don't try to reach Surph's servers, cookies or session; a page can't, and it doesn't need to.
|
|
395
|
+
- **Change the app only through operations, and only when the person asked.** `context.subscribe`, `context.open`, `context.edit`, `profile.open`, `page.open` and `chat.say` act for the signed-in person. Call them from that person's own click, except a start page routing on load with `page.open`.
|
|
396
|
+
- **Work when not hosted.** Opened directly, `host.hosted` is false and every `rpc` rejects. The page renders its empty layout without errors and stops retrying, which the call pattern already does.
|
|
397
|
+
- **One `ready()` per page load**, called after `connect`.
|
|
398
|
+
- **Don't invent operations or fields.** If a page needs data the operations above don't provide, say so in your hand-off; a new operation needs a change in Surph first.
|
|
399
|
+
- **Keep the registry in step.** A page added, renamed or removed, or with changed `rpc` calls, needs its registry entry in your hand-off.
|
|
400
|
+
- **Plain, static files.** No build step is needed or expected. Scripts can live inline or under `assets/js/`; keep the SDK `<script>` tag as given.
|
|
401
|
+
|
|
402
|
+
## Developing locally
|
|
403
|
+
|
|
404
|
+
1. Install once with `npm install`, then run `npm run dev`. It starts the board dev server from `@surph_ai/sdk` (`surph-board-dev`), which serves this folder at `http://localhost:4180` with caching off, lets Surph read it from another address, and lets Surph's Local board panel save `board.json`. It only listens on your own machine.
|
|
405
|
+
2. Keep it running while you work; the terminal shows a line each time Surph saves `board.json`.
|
|
406
|
+
3. Open `http://localhost:4180/` to check a page loads without console errors. It shows its empty layout there (`hosted` is false); real data only appears inside the Surph canvas.
|
|
407
|
+
4. **Run Surph as your board with dev mode.** Keep `npm run dev` running. In Surph, open **Settings → Developer**, turn on **Run Surph as a local board**, and keep the address `http://localhost:4180`. Surph reloads as the board `board.json` describes: its name and logo in the header, its contexts and instructions in the chat, and its pages in the canvas (it opens on your start page, the Pages menu lists your pages, and the chat's model sees their `shows` lines). The status under the address says what loaded, or why it couldn't read `board.json`; until it loads, Surph runs as usual. A **DEV** label in the canvas header shows your board is loaded. While it's on, anything the chat saves goes to your own space in Surph, not to a board's shared space.
|
|
408
|
+
5. **Configure the board from Surph.** Click the **DEV** label to open the **Local board** panel: set the board's name, slug, description and logo (an image in `assets/`), pick its contexts (yours, or public ones by search) and which one the chat starts with, pick its tools, and write its instructions. The panel also lists the pages and flags a missing `shows` line or an operation the host doesn't answer. **Save and reload** writes `board.json` through the dev server and reloads Surph as the updated board. Pages and any other fields (such as `waves`) are kept as they are in the file; edit pages in `board.json` itself.
|
|
409
|
+
6. Dev mode affects only your browser. Turn it off in the same place to get Surph's own pages back.
|
|
410
|
+
7. In Surph, keep your browser's DevTools open with **Network → Disable cache** checked, or the canvas may keep showing an older copy of a page after you change it. After editing `board.json` by hand, reload Surph so it reads the file again.
|
|
411
|
+
|
|
412
|
+
## Checklist before handing off a change
|
|
413
|
+
|
|
414
|
+
- [ ] Every `host.rpc(…)` call uses an operation from [Operations the host answers](#operations-the-host-answers) and reads only the fields listed for it and for [Item](#item).
|
|
415
|
+
- [ ] Reads use the call pattern: a timeout retries, other errors stop, nothing is cleared before a reply.
|
|
416
|
+
- [ ] Data from Surph is written with `textContent`; links and images use only `http(s)` values.
|
|
417
|
+
- [ ] Operations that act for the person run only from their click (a start page's `page.open` aside).
|
|
418
|
+
- [ ] The SDK `<script>` tag is the v1 URL above, unchanged.
|
|
419
|
+
- [ ] Every page links `assets/css/site.css?v=<n>`; if `site.css` changed, `<n>` is bumped in every page.
|
|
420
|
+
- [ ] `board.json`'s `config.pages` lists every page: `name`, `file` (or `url`), `shows` written as facts, `ops` matching the file's `rpc` calls, and one `start` page.
|
|
421
|
+
- [ ] Opened at `http://localhost:4180/`, every page loads without console errors.
|
|
422
|
+
- [ ] Checked in Surph with dev mode on: the canvas opens on the start page and each page paints with real data.
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/* Shared styles for every page in this set. Linked from each page as
|
|
2
|
+
assets/css/site.css?v=<n> — bump <n> on every change (S3 caches it).
|
|
3
|
+
Each set (base, each board) has its own copy; they start the same and
|
|
4
|
+
may diverge.
|
|
5
|
+
A page's own <style> holds only what that page alone uses. */
|
|
6
|
+
|
|
7
|
+
:root {
|
|
8
|
+
--ink: #1e0a3c; /* text */
|
|
9
|
+
--muted: #4b5563; /* secondary text */
|
|
10
|
+
--faint: #6b7280; /* empty / status lines */
|
|
11
|
+
--subtle: #9ca3af; /* dates, link icons */
|
|
12
|
+
--accent: #6b5b8a; /* section headings, initials */
|
|
13
|
+
--rule: #ece7f3; /* row dividers */
|
|
14
|
+
--tint: #f6f3fa; /* row hover */
|
|
15
|
+
--tint-strong: #f3f0f8; /* icon placeholder */
|
|
16
|
+
--pill: #f1eff6; /* owner pill */
|
|
17
|
+
--pill-hover: #e4e0ec;
|
|
18
|
+
--owner: #1f7f78; /* owner pill text */
|
|
19
|
+
--owner-hover: #145f5a;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Page shell */
|
|
23
|
+
|
|
24
|
+
html, body {
|
|
25
|
+
margin: 0;
|
|
26
|
+
background: #fff;
|
|
27
|
+
color: var(--ink);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
body {
|
|
31
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
32
|
+
padding: 28px 24px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
h1 {
|
|
36
|
+
margin: 0;
|
|
37
|
+
font-size: 1.35rem;
|
|
38
|
+
font-weight: 650;
|
|
39
|
+
line-height: 1.3;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* A section heading (filled in once the section's data arrives). */
|
|
43
|
+
h2 {
|
|
44
|
+
margin: 1.75rem 0 0;
|
|
45
|
+
font-size: 0.8rem;
|
|
46
|
+
font-weight: 650;
|
|
47
|
+
letter-spacing: 0.02em;
|
|
48
|
+
color: var(--accent);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
h2:empty {
|
|
52
|
+
display: none;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* The line shown when a list has nothing in it. */
|
|
56
|
+
p.empty {
|
|
57
|
+
margin: 0.5rem 0 0;
|
|
58
|
+
font-size: 0.9rem;
|
|
59
|
+
color: var(--faint);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Context lists (landing, explore, profile): <ul class="contexts">, each
|
|
63
|
+
<li> holding one .context row. --icon-size sets the icon square; a list
|
|
64
|
+
whose rows have no owner pill sets it smaller. */
|
|
65
|
+
|
|
66
|
+
ul.contexts {
|
|
67
|
+
--icon-size: 64px;
|
|
68
|
+
list-style: none;
|
|
69
|
+
margin: 0.5rem 0 0;
|
|
70
|
+
padding: 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
ul.contexts > li {
|
|
74
|
+
border-top: 1px solid var(--rule);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* The row: icon, then name / description / owner. A <button>, or a
|
|
78
|
+
div[role=button] when it holds the owner's button. Scoped to
|
|
79
|
+
ul.contexts: other pages use a .context class of their own. */
|
|
80
|
+
ul.contexts .context {
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
gap: 12px;
|
|
84
|
+
width: 100%;
|
|
85
|
+
padding: 0.7rem 0.5rem;
|
|
86
|
+
margin: 0 -0.5rem;
|
|
87
|
+
box-sizing: content-box;
|
|
88
|
+
background: transparent;
|
|
89
|
+
border: none;
|
|
90
|
+
border-radius: 8px;
|
|
91
|
+
color: inherit;
|
|
92
|
+
font: inherit;
|
|
93
|
+
text-align: left;
|
|
94
|
+
cursor: pointer;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
ul.contexts .context:hover,
|
|
98
|
+
ul.contexts .context:focus-visible {
|
|
99
|
+
background: var(--tint);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* The icon square; the image fills it, else it shows the name's initial. */
|
|
103
|
+
ul.contexts .context > .icon {
|
|
104
|
+
position: relative;
|
|
105
|
+
flex-shrink: 0;
|
|
106
|
+
width: var(--icon-size);
|
|
107
|
+
height: var(--icon-size);
|
|
108
|
+
border-radius: 10px;
|
|
109
|
+
overflow: hidden;
|
|
110
|
+
background: var(--tint-strong);
|
|
111
|
+
display: flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
justify-content: center;
|
|
114
|
+
font-weight: 650;
|
|
115
|
+
color: var(--accent);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
ul.contexts .context > .icon img {
|
|
119
|
+
position: absolute;
|
|
120
|
+
inset: 0;
|
|
121
|
+
width: 100%;
|
|
122
|
+
height: 100%;
|
|
123
|
+
object-fit: cover;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
ul.contexts .context .text {
|
|
127
|
+
flex: 1;
|
|
128
|
+
min-width: 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* The line under the description: owner pill, then the settings (edit) button. */
|
|
132
|
+
ul.contexts .context .meta {
|
|
133
|
+
display: flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
gap: 0.25rem;
|
|
136
|
+
margin-top: 0.35rem;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
ul.contexts .context .meta button.owner {
|
|
140
|
+
margin-top: 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* Settings (gear): only on contexts this user created or collaborates on
|
|
144
|
+
(canEdit); opens the context builder (context.edit). */
|
|
145
|
+
ul.contexts .context button.edit {
|
|
146
|
+
flex-shrink: 0;
|
|
147
|
+
display: flex;
|
|
148
|
+
align-items: center;
|
|
149
|
+
justify-content: center;
|
|
150
|
+
width: 24px;
|
|
151
|
+
height: 24px;
|
|
152
|
+
padding: 0;
|
|
153
|
+
border: none;
|
|
154
|
+
border-radius: 999px;
|
|
155
|
+
background: transparent;
|
|
156
|
+
color: var(--subtle);
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
ul.contexts .context button.edit:hover,
|
|
161
|
+
ul.contexts .context button.edit:focus-visible {
|
|
162
|
+
background: var(--pill-hover);
|
|
163
|
+
color: var(--accent);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
ul.contexts .context button.edit svg {
|
|
167
|
+
width: 14px;
|
|
168
|
+
height: 14px;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
ul.contexts .context strong {
|
|
172
|
+
display: block;
|
|
173
|
+
font-size: 0.95rem;
|
|
174
|
+
font-weight: 650;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
ul.contexts .context span.description {
|
|
178
|
+
display: block;
|
|
179
|
+
margin-top: 0.15rem;
|
|
180
|
+
font-size: 0.85rem;
|
|
181
|
+
line-height: 1.4;
|
|
182
|
+
color: var(--muted);
|
|
183
|
+
white-space: nowrap;
|
|
184
|
+
overflow: hidden;
|
|
185
|
+
text-overflow: ellipsis;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* The context's owner: avatar + username, as in the chat's pills. A
|
|
189
|
+
<button> that opens their profile (profile.open). */
|
|
190
|
+
button.owner {
|
|
191
|
+
display: inline-flex;
|
|
192
|
+
align-items: center;
|
|
193
|
+
gap: 0.35rem;
|
|
194
|
+
margin-top: 0.35rem;
|
|
195
|
+
padding: 0.2rem 0.6rem 0.2rem 0.25rem;
|
|
196
|
+
border: none;
|
|
197
|
+
border-radius: 999px;
|
|
198
|
+
background: var(--pill);
|
|
199
|
+
color: var(--owner);
|
|
200
|
+
font-family: inherit;
|
|
201
|
+
font-size: 0.78rem;
|
|
202
|
+
font-weight: 500;
|
|
203
|
+
line-height: 1.2;
|
|
204
|
+
white-space: nowrap;
|
|
205
|
+
cursor: pointer;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
button.owner:hover,
|
|
209
|
+
button.owner:focus-visible {
|
|
210
|
+
background: var(--pill-hover);
|
|
211
|
+
color: var(--owner-hover);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
button.owner.no-avatar {
|
|
215
|
+
padding-left: 0.6rem;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
button.owner img {
|
|
219
|
+
width: 16px;
|
|
220
|
+
height: 16px;
|
|
221
|
+
border-radius: 50%;
|
|
222
|
+
object-fit: cover;
|
|
223
|
+
background: var(--pill-hover);
|
|
224
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "my-board",
|
|
3
|
+
"name": "My Board",
|
|
4
|
+
"description": "",
|
|
5
|
+
"logo": "",
|
|
6
|
+
"contexts": [],
|
|
7
|
+
"defaultContext": "",
|
|
8
|
+
"waves": [],
|
|
9
|
+
"tools": [],
|
|
10
|
+
"config": {
|
|
11
|
+
"instructions": "",
|
|
12
|
+
"pages": [
|
|
13
|
+
{
|
|
14
|
+
"name": "home",
|
|
15
|
+
"file": "index.html",
|
|
16
|
+
"shows": "The board's home page: an introduction to how this board's pages work in Surph, and whether the page is running inside Surph.",
|
|
17
|
+
"start": "always",
|
|
18
|
+
"ops": ["board.get"]
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
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">
|
|
6
|
+
<title>your surph board</title>
|
|
7
|
+
<link rel="stylesheet" href="assets/css/site.css?v=1">
|
|
8
|
+
<style>
|
|
9
|
+
p.lead {
|
|
10
|
+
margin: 0.5rem 0 0;
|
|
11
|
+
font-size: 0.95rem;
|
|
12
|
+
line-height: 1.5;
|
|
13
|
+
color: var(--muted);
|
|
14
|
+
}
|
|
15
|
+
ol.steps,
|
|
16
|
+
ul.facts {
|
|
17
|
+
margin: 0.6rem 0 0;
|
|
18
|
+
padding-left: 1.2rem;
|
|
19
|
+
font-size: 0.9rem;
|
|
20
|
+
line-height: 1.55;
|
|
21
|
+
color: var(--muted);
|
|
22
|
+
}
|
|
23
|
+
ol.steps li,
|
|
24
|
+
ul.facts li {
|
|
25
|
+
margin-top: 0.4rem;
|
|
26
|
+
}
|
|
27
|
+
ol.steps strong,
|
|
28
|
+
ul.facts strong {
|
|
29
|
+
color: var(--ink);
|
|
30
|
+
}
|
|
31
|
+
code {
|
|
32
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
33
|
+
font-size: 0.82rem;
|
|
34
|
+
padding: 0.05rem 0.3rem;
|
|
35
|
+
border-radius: 4px;
|
|
36
|
+
background: var(--tint);
|
|
37
|
+
color: var(--ink);
|
|
38
|
+
}
|
|
39
|
+
/* Where this page is running: inside Surph's canvas, or opened directly. */
|
|
40
|
+
.status {
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: flex-start;
|
|
43
|
+
gap: 10px;
|
|
44
|
+
margin-top: 0.75rem;
|
|
45
|
+
padding: 0.75rem 0.9rem;
|
|
46
|
+
border: 1px solid var(--rule);
|
|
47
|
+
border-radius: 10px;
|
|
48
|
+
font-size: 0.9rem;
|
|
49
|
+
line-height: 1.45;
|
|
50
|
+
color: var(--muted);
|
|
51
|
+
}
|
|
52
|
+
.status .dot {
|
|
53
|
+
flex-shrink: 0;
|
|
54
|
+
width: 10px;
|
|
55
|
+
height: 10px;
|
|
56
|
+
margin-top: 0.35rem;
|
|
57
|
+
border-radius: 50%;
|
|
58
|
+
background: var(--subtle);
|
|
59
|
+
}
|
|
60
|
+
.status.is-hosted .dot {
|
|
61
|
+
background: var(--owner);
|
|
62
|
+
}
|
|
63
|
+
.status strong {
|
|
64
|
+
display: block;
|
|
65
|
+
color: var(--ink);
|
|
66
|
+
}
|
|
67
|
+
</style>
|
|
68
|
+
</head>
|
|
69
|
+
<body>
|
|
70
|
+
<h1>your surph board</h1>
|
|
71
|
+
<p class="lead">This page is part of a Surph board. Surph shows it in the <strong>canvas</strong>, the panel beside its chat, and answers its requests for data. Edit <code>index.html</code> to make it your board's home page.</p>
|
|
72
|
+
|
|
73
|
+
<div class="status" id="status">
|
|
74
|
+
<span class="dot" aria-hidden="true"></span>
|
|
75
|
+
<div>
|
|
76
|
+
<strong id="status-title">Checking…</strong>
|
|
77
|
+
<span id="status-text"></span>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<h2>How a page works in Surph</h2>
|
|
82
|
+
<ul class="facts">
|
|
83
|
+
<li><strong>It's a static HTML file.</strong> No build step or framework. Surph loads it in an iframe; you can host it anywhere on <code>https</code>.</li>
|
|
84
|
+
<li><strong>It talks to Surph through the SDK.</strong> The page loads <code>surph-wave.js</code>, calls <code>SurphWave.connect()</code>, then asks the host for data with <code>host.rpc(op, args)</code>: the board's recent items, the chat's context, the items the chat looked up, and more. The page never calls Surph's servers itself.</li>
|
|
85
|
+
<li><strong>The chat decides when to show it.</strong> Each page is registered with a <code>shows</code> line, plain facts about what it displays. The chat's model reads those and opens the page that fits what the person asked. A page can also take values in its address (<code>?ids=…</code>) that the chat fills in.</li>
|
|
86
|
+
<li><strong>It updates as the chat goes.</strong> When the host's data changes, it calls the page's <code>onRefresh</code>, and the page asks again and repaints.</li>
|
|
87
|
+
<li><strong>Opened directly, it still loads.</strong> Outside Surph, <code>host.hosted</code> is false and every call rejects, so the page shows its empty layout. That's what you see here when you run it locally.</li>
|
|
88
|
+
</ul>
|
|
89
|
+
|
|
90
|
+
<h2>Next steps</h2>
|
|
91
|
+
<ol class="steps">
|
|
92
|
+
<li><strong>Read <code>README.md</code>.</strong> It lists every operation the host answers, the item shape, and the rules a page follows. Point your coding agent at it.</li>
|
|
93
|
+
<li><strong>Run it locally:</strong> <code>npm run dev</code> serves this folder at <code>http://localhost:4180</code> with caching off.</li>
|
|
94
|
+
<li><strong>Add pages</strong> next to this one (<code>items.html</code>, <code>events.html</code>…), and list each in <code>board.json</code> with its <code>name</code>, <code>file</code>, <code>shows</code> and the <code>ops</code> it calls. Shared styles go in <code>assets/css/site.css</code>.</li>
|
|
95
|
+
<li><strong>See them in Surph:</strong> open <strong>Settings → Developer</strong> and turn on dev mode for <code>http://localhost:4180</code>. The canvas then shows this folder's pages, with real data.</li>
|
|
96
|
+
</ol>
|
|
97
|
+
|
|
98
|
+
<script src="https://surph.s3.us-east-1.amazonaws.com/admin/desktop/js/wave-sdk/v1/surph-wave.js"></script>
|
|
99
|
+
<script>
|
|
100
|
+
const host = SurphWave.connect({
|
|
101
|
+
pageSlug: 'index',
|
|
102
|
+
onRefresh: function () { paint(); }
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const statusBox = document.getElementById('status');
|
|
106
|
+
const statusTitle = document.getElementById('status-title');
|
|
107
|
+
const statusText = document.getElementById('status-text');
|
|
108
|
+
|
|
109
|
+
function setStatus(hosted, title, text) {
|
|
110
|
+
statusBox.classList.toggle('is-hosted', hosted);
|
|
111
|
+
statusTitle.textContent = title;
|
|
112
|
+
statusText.textContent = text;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The call pattern every page uses: a timeout means the host is still
|
|
116
|
+
// loading (ask again shortly), any other error means stop, and the page
|
|
117
|
+
// paints only after a successful reply.
|
|
118
|
+
let painting = false;
|
|
119
|
+
async function paint() {
|
|
120
|
+
if (!host.hosted) {
|
|
121
|
+
setStatus(false, 'Opened directly, not inside Surph', 'Host calls reject here, so this page shows its layout only. Inside the Surph canvas, this box shows your board.');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (painting) return;
|
|
125
|
+
painting = true;
|
|
126
|
+
let got;
|
|
127
|
+
try {
|
|
128
|
+
got = await host.rpc('board.get', {}, { timeout: 800 });
|
|
129
|
+
} catch (e) {
|
|
130
|
+
painting = false;
|
|
131
|
+
if (e && e.message === 'host timeout') {
|
|
132
|
+
setStatus(true, 'Inside Surph', 'Loading the board…');
|
|
133
|
+
setTimeout(paint, 200);
|
|
134
|
+
} else {
|
|
135
|
+
setStatus(true, 'Inside Surph', 'The host answered: ' + ((e && e.message) || 'an error') + '.');
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
painting = false;
|
|
140
|
+
const name = got && typeof got.name === 'string' ? got.name : '';
|
|
141
|
+
const count = Array.isArray(got && got.items) ? got.items.length : 0;
|
|
142
|
+
setStatus(true, 'Inside Surph' + (name ? ': ' + name : ''),
|
|
143
|
+
name
|
|
144
|
+
? 'board.get answered with ' + count + ' recent item' + (count === 1 ? '' : 's') + ' from this board.'
|
|
145
|
+
: 'board.get answered: this chat isn’t on a board, so it has no board items.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
host.ready();
|
|
149
|
+
paint();
|
|
150
|
+
</script>
|
|
151
|
+
</body>
|
|
152
|
+
</html>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "surph-board",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "surph-board-dev"
|
|
7
|
+
},
|
|
8
|
+
"keywords": [],
|
|
9
|
+
"author": "",
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"type": "commonjs",
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@surph_ai/sdk": "latest"
|
|
14
|
+
}
|
|
15
|
+
}
|
package/scaffold/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// const program = require('commander')
|
|
2
|
+
// const fs = require('fs')
|
|
3
|
+
// const dotenv = require('dotenv')
|
|
2
4
|
const shell = require('shelljs')
|
|
3
5
|
const path = require('path')
|
|
4
|
-
const fs = require('fs')
|
|
5
|
-
const dotenv = require('dotenv')
|
|
6
6
|
const colors = require('colors/safe')
|
|
7
7
|
const utils = require('../utils')
|
|
8
8
|
const { StepProgress } = require('../utils/progress')
|
|
@@ -22,6 +22,17 @@ const TOOL_STEPS = [
|
|
|
22
22
|
{ id: 'devserver', doing: 'Starting dev server', done: 'Dev server starting', weight: 8 }
|
|
23
23
|
]
|
|
24
24
|
|
|
25
|
+
const BOARD_STEPS = [
|
|
26
|
+
{ id: 'directory', doing: 'Creating project directory', done: 'Project directory created', weight: 1 },
|
|
27
|
+
{ id: 'package', doing: 'Writing package.json', done: 'package.json written', weight: 1 },
|
|
28
|
+
{ id: 'examples', doing: 'Copying example tools', done: 'Example tools copied', weight: 2 },
|
|
29
|
+
{ id: 'manifests', doing: 'Writing manifests', done: 'Manifests written', weight: 1 },
|
|
30
|
+
{ id: 'config', doing: 'Writing .env and tsconfig.json', done: '.env and tsconfig.json written', weight: 1 },
|
|
31
|
+
{ id: 'cheatsheet', doing: 'Fetching cheatsheet', done: 'Cheatsheet fetched', weight: 4 },
|
|
32
|
+
{ id: 'install', doing: 'Installing dependencies', done: 'Dependencies installed', weight: 82 },
|
|
33
|
+
{ id: 'devserver', doing: 'Starting dev server', done: 'Dev server starting', weight: 8 }
|
|
34
|
+
]
|
|
35
|
+
|
|
25
36
|
// Expected package count for install sub-progress; grows if the tree is larger.
|
|
26
37
|
// Used as clamp for install sub-progress. Never shown to the user.
|
|
27
38
|
const EXPECTED_PACKAGES = 324
|
|
@@ -241,6 +252,122 @@ const scaffoldTool = async (name) => {
|
|
|
241
252
|
shell.exec('npm run dev')
|
|
242
253
|
}
|
|
243
254
|
|
|
255
|
+
const scaffoldBoard = async (name) => {
|
|
256
|
+
const progress = new StepProgress(BOARD_STEPS)
|
|
257
|
+
console.log(progress.paint(colors.cyan, `\nCreating Surph board: ${name}\n`))
|
|
258
|
+
progress.start()
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
progress.begin('directory')
|
|
262
|
+
shell.mkdir('-p', name)
|
|
263
|
+
shell.cd(name)
|
|
264
|
+
await progress.done()
|
|
265
|
+
|
|
266
|
+
progress.begin('package')
|
|
267
|
+
const pkgJsonStr = utils.readFile(path.join(__dirname, 'board/package.json'))
|
|
268
|
+
const pkgJson = JSON.parse(pkgJsonStr)
|
|
269
|
+
pkgJson.name = name
|
|
270
|
+
utils.write('package.json', JSON.stringify(pkgJson, null, 2) + '\n')
|
|
271
|
+
|
|
272
|
+
shell.cp('-R', path.join(__dirname, `board/README.md`), 'README.md')
|
|
273
|
+
shell.cp('-R', path.join(__dirname, `board/index.html`), 'index.html')
|
|
274
|
+
shell.cp('-R', path.join(__dirname, `board/board.json`), 'board.json')
|
|
275
|
+
|
|
276
|
+
shell.mkdir('-p', 'assets')
|
|
277
|
+
shell.mkdir('-p', 'assets/css')
|
|
278
|
+
shell.mkdir('-p', 'assets/js')
|
|
279
|
+
shell.cp('-R', path.join(__dirname, `board/assets/css/site.css`), 'assets/css/site.css')
|
|
280
|
+
|
|
281
|
+
// shell.cp('-R', path.join(__dirname, `board/assets/js/wave-sdk/v1/surph-wave.js`), 'assets/js/surph-wave.js')
|
|
282
|
+
|
|
283
|
+
await progress.done()
|
|
284
|
+
|
|
285
|
+
progress.begin('install')
|
|
286
|
+
await installDependencies(progress, process.cwd())
|
|
287
|
+
await progress.done()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
/*
|
|
291
|
+
progress.begin('examples')
|
|
292
|
+
shell.mkdir('-p', 'tools')
|
|
293
|
+
shell.cp('-R', path.join(__dirname, `tool/tools/index.ts`), 'tools/index.ts')
|
|
294
|
+
|
|
295
|
+
shell.mkdir('-p', 'examples')
|
|
296
|
+
shell.cp('-R', path.join(__dirname, `tool/examples/README.md`), 'examples/README.md')
|
|
297
|
+
|
|
298
|
+
shell.mkdir('-p', 'examples/population')
|
|
299
|
+
shell.cp('-R', path.join(__dirname, `tool/examples/population/index.ts`), 'examples/population/index.ts')
|
|
300
|
+
shell.cp('-R', path.join(__dirname, `tool/examples/population/manifest.json`), 'examples/population/manifest.json')
|
|
301
|
+
|
|
302
|
+
shell.mkdir('-p', 'examples/weather')
|
|
303
|
+
shell.cp('-R', path.join(__dirname, `tool/examples/weather/index.ts`), 'examples/weather/index.ts')
|
|
304
|
+
shell.cp('-R', path.join(__dirname, `tool/examples/weather/manifest.json`), 'examples/weather/manifest.json')
|
|
305
|
+
await progress.done()
|
|
306
|
+
|
|
307
|
+
progress.begin('manifests')
|
|
308
|
+
utils.write('manifest-example.json', JSON.stringify(manifestExample, null, 2) + '\n')
|
|
309
|
+
|
|
310
|
+
const slug = slugify(name, 8)
|
|
311
|
+
const manifest = {}
|
|
312
|
+
manifest[slug] = {
|
|
313
|
+
name,
|
|
314
|
+
slug,
|
|
315
|
+
type: 'custom',
|
|
316
|
+
description: '',
|
|
317
|
+
parameters: {
|
|
318
|
+
type: "object",
|
|
319
|
+
properties: {},
|
|
320
|
+
required: []
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
utils.write('manifest.json', JSON.stringify(manifest, null, 2) + '\n')
|
|
325
|
+
await progress.done()
|
|
326
|
+
|
|
327
|
+
progress.begin('config')
|
|
328
|
+
const envStr = utils.readFile(path.join(__dirname, 'tool/env.txt'))
|
|
329
|
+
utils.write('.env', envStr.replace('<SURPH_TOOL>', slug))
|
|
330
|
+
utils.write('tsconfig.json', JSON.stringify(tsConfig, null, 2) + '\n')
|
|
331
|
+
await progress.done()
|
|
332
|
+
|
|
333
|
+
progress.begin('cheatsheet')
|
|
334
|
+
try {
|
|
335
|
+
const cheatsheetResp = await fetch(CHEATSHEET_URL)
|
|
336
|
+
if (!cheatsheetResp.ok) {
|
|
337
|
+
throw new Error(`HTTP ${cheatsheetResp.status}`)
|
|
338
|
+
}
|
|
339
|
+
utils.write('CHEATSHEET.md', await cheatsheetResp.text())
|
|
340
|
+
} catch (error) {
|
|
341
|
+
// Cosmetic — a reference doc that didn't download is no reason to fail
|
|
342
|
+
// a scaffold that is otherwise complete.
|
|
343
|
+
progress.warn(`Could not fetch CHEATSHEET.md (${error.message}) - skipped.`)
|
|
344
|
+
}
|
|
345
|
+
await progress.done()
|
|
346
|
+
|
|
347
|
+
progress.begin('install')
|
|
348
|
+
await installDependencies(progress, process.cwd())
|
|
349
|
+
await progress.done()
|
|
350
|
+
|
|
351
|
+
progress.begin('devserver')
|
|
352
|
+
*/
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
await progress.complete()
|
|
356
|
+
} catch (error) {
|
|
357
|
+
progress.fail(error)
|
|
358
|
+
process.exitCode = 1
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
console.log('\n- - - - - Next Steps - - - - - ')
|
|
363
|
+
// console.log('Open codebase in a code editor and navigate to /tools/index.ts - this is where your tool code goes.')
|
|
364
|
+
// console.log('\nFor full documentation, visit https://docs.surph.ai\n')
|
|
365
|
+
// console.log('Starting the dev server - your browser will open at http://localhost:6767')
|
|
366
|
+
// console.log('To stop it, press Ctrl+C. To start it again, run: npm run dev\n')
|
|
367
|
+
|
|
368
|
+
// shell.exec('npm run dev')
|
|
369
|
+
}
|
|
370
|
+
|
|
244
371
|
module.exports = async (name, type = 'app') => {
|
|
245
372
|
/*
|
|
246
373
|
https://stackoverflow.com/questions/8683895/how-do-i-determine-the-current-operating-system-with-node-js
|
|
@@ -255,4 +382,8 @@ module.exports = async (name, type = 'app') => {
|
|
|
255
382
|
if (type === 'tool') {
|
|
256
383
|
await scaffoldTool(name)
|
|
257
384
|
}
|
|
385
|
+
|
|
386
|
+
if (type === 'board') {
|
|
387
|
+
await scaffoldBoard(name)
|
|
388
|
+
}
|
|
258
389
|
}
|
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
2
|
+
"name": "surph-board",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
5
|
"scripts": {
|
|
6
|
-
"
|
|
7
|
-
"dev": "nodemon",
|
|
8
|
-
"test": "",
|
|
9
|
-
"build": ""
|
|
10
|
-
},
|
|
11
|
-
"dependencies": {
|
|
12
|
-
"@turbo360/turbo-sdk": "^0.0.8",
|
|
13
|
-
"cookie": "^0.5.0",
|
|
14
|
-
"express": "^4.18.1",
|
|
15
|
-
"mustache": "^4.2.0",
|
|
16
|
-
"saxa": "^0.0.26"
|
|
6
|
+
"dev": "http-server -p 4180 -c-1"
|
|
17
7
|
},
|
|
8
|
+
"keywords": [],
|
|
9
|
+
"author": "",
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"type": "commonjs",
|
|
18
12
|
"devDependencies": {
|
|
19
|
-
"
|
|
13
|
+
"http-server": "^14.1.1"
|
|
20
14
|
}
|
|
21
15
|
}
|