@lotics/app-sdk 0.100.0 → 0.101.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/AGENTS.md +32 -47
- package/dist/agent_stream.d.ts +131 -0
- package/dist/ask_ai.d.ts +27 -0
- package/dist/attachments.d.ts +58 -0
- package/dist/chunk-ARV5FAU5.js +1132 -0
- package/dist/comments.d.ts +89 -0
- package/dist/error_report.d.ts +9 -0
- package/dist/folder_pick.d.ts +8 -0
- package/dist/geolocation.d.ts +42 -0
- package/dist/hooks.d.ts +251 -0
- package/dist/{src/index.d.ts → index.d.ts} +13 -22
- package/dist/index.js +31309 -0
- package/dist/index.js.LEGAL.txt +11 -0
- package/dist/members.d.ts +32 -0
- package/dist/mock.d.ts +37 -0
- package/dist/mount.d.ts +19 -0
- package/dist/new_record.d.ts +37 -0
- package/dist/open_app.d.ts +12 -0
- package/dist/open_external.d.ts +10 -0
- package/dist/overlay.d.ts +25 -0
- package/dist/queries.d.ts +231 -0
- package/dist/recording.d.ts +47 -0
- package/dist/recording_state.d.ts +43 -0
- package/dist/rename_file.d.ts +13 -0
- package/dist/router.d.ts +10 -0
- package/dist/router.js +97 -0
- package/dist/row.d.ts +87 -0
- package/dist/rpc.d.ts +114 -0
- package/dist/select.d.ts +24 -0
- package/dist/shared_types.d.ts +8 -0
- package/dist/store.d.ts +43 -0
- package/dist/types.d.ts +36 -0
- package/dist/upload/optimize.d.ts +30 -0
- package/dist/upload/pipeline.d.ts +36 -0
- package/dist/upload/transport.d.ts +19 -0
- package/dist/url_params.d.ts +55 -0
- package/dist/use_recents.d.ts +15 -0
- package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
- package/dist/viewer.d.ts +41 -0
- package/dist/written.d.ts +77 -0
- package/docs/ai.md +74 -133
- package/docs/data_fetching.md +209 -290
- package/docs/files.md +61 -51
- package/docs/members_and_options.md +93 -63
- package/docs/mutations.md +135 -205
- package/docs/navigation_and_state.md +26 -35
- package/docs/queries.md +144 -207
- package/docs/recipes.md +21 -45
- package/docs/runtime.md +74 -137
- package/docs/security.md +8 -11
- package/docs/workflows.md +189 -174
- package/package.json +27 -28
- package/dist/src/agent_stream.d.ts +0 -200
- package/dist/src/agent_stream.js +0 -314
- package/dist/src/ask_ai.d.ts +0 -40
- package/dist/src/ask_ai.js +0 -35
- package/dist/src/attachments.d.ts +0 -68
- package/dist/src/attachments.js +0 -93
- package/dist/src/comments.d.ts +0 -127
- package/dist/src/comments.js +0 -192
- package/dist/src/download.js +0 -54
- package/dist/src/geolocation.d.ts +0 -64
- package/dist/src/geolocation.js +0 -96
- package/dist/src/hooks.d.ts +0 -781
- package/dist/src/hooks.js +0 -860
- package/dist/src/index.js +0 -34
- package/dist/src/members.d.ts +0 -105
- package/dist/src/members.js +0 -62
- package/dist/src/mock.d.ts +0 -118
- package/dist/src/mock.js +0 -124
- package/dist/src/mount.d.ts +0 -47
- package/dist/src/mount.js +0 -34
- package/dist/src/new_record.d.ts +0 -74
- package/dist/src/new_record.js +0 -117
- package/dist/src/open_app.d.ts +0 -15
- package/dist/src/open_app.js +0 -18
- package/dist/src/open_external.d.ts +0 -16
- package/dist/src/open_external.js +0 -19
- package/dist/src/recording.d.ts +0 -59
- package/dist/src/recording.js +0 -30
- package/dist/src/recording_state.d.ts +0 -59
- package/dist/src/recording_state.js +0 -94
- package/dist/src/router.d.ts +0 -17
- package/dist/src/router.js +0 -144
- package/dist/src/row.d.ts +0 -159
- package/dist/src/row.js +0 -254
- package/dist/src/rpc.d.ts +0 -207
- package/dist/src/rpc.js +0 -904
- package/dist/src/select.d.ts +0 -34
- package/dist/src/select.js +0 -40
- package/dist/src/types.d.ts +0 -115
- package/dist/src/types.js +0 -1
- package/dist/src/upload/optimize.d.ts +0 -54
- package/dist/src/upload/optimize.js +0 -207
- package/dist/src/upload/pipeline.d.ts +0 -55
- package/dist/src/upload/pipeline.js +0 -52
- package/dist/src/upload/transport.d.ts +0 -42
- package/dist/src/upload/transport.js +0 -128
- package/dist/src/url_params.d.ts +0 -93
- package/dist/src/url_params.js +0 -215
- package/dist/src/use_optimistic.d.ts +0 -27
- package/dist/src/use_optimistic.js +0 -27
- package/dist/src/use_recents.d.ts +0 -19
- package/dist/src/use_recents.js +0 -71
- package/dist/src/use_url_state.js +0 -73
- package/dist/src/viewer.d.ts +0 -26
- package/dist/src/viewer.js +0 -47
- /package/dist/{src/download.d.ts → download.d.ts} +0 -0
package/dist/src/rpc.js
DELETED
|
@@ -1,904 +0,0 @@
|
|
|
1
|
-
import { runUploadPipeline } from "./upload/pipeline.js";
|
|
2
|
-
import { applyRecordingChange, readRecordingState, recordingStateOf } from "./recording_state.js";
|
|
3
|
-
import { parseSearch, serializeMerge, } from "./url_params.js";
|
|
4
|
-
/**
|
|
5
|
-
* The embedding Lotics host's origin — present iff the app is bridged.
|
|
6
|
-
*
|
|
7
|
-
* Lazy + memoized so the module's top level doesn't touch `window`. A test
|
|
8
|
-
* runner can pull this module in at evaluation time before its jsdom
|
|
9
|
-
* environment finishes setting up `window.location` — an eager read would
|
|
10
|
-
* crash every suite that transitively imports the SDK.
|
|
11
|
-
*
|
|
12
|
-
* `undefined` = not yet computed; `string | null` = computed result.
|
|
13
|
-
*/
|
|
14
|
-
let hostOriginCache;
|
|
15
|
-
function getHostOrigin() {
|
|
16
|
-
if (hostOriginCache === undefined) {
|
|
17
|
-
hostOriginCache = new URLSearchParams(window.location.search).get("lotics_host");
|
|
18
|
-
}
|
|
19
|
-
return hostOriginCache;
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Whether the app is running embedded in a Lotics host (vs. standalone at its
|
|
23
|
-
* own origin). `rpc()`, `useUrlState`, and `AppRouter` use this to
|
|
24
|
-
* pick the transport / behaviour; an app rarely needs it directly.
|
|
25
|
-
*/
|
|
26
|
-
export function isEmbedded() {
|
|
27
|
-
// Truthiness, not `!== null`: a present-but-empty `?lotics_host=` yields "",
|
|
28
|
-
// which is no usable host origin — so it's standalone, matching how `rpc()`
|
|
29
|
-
// branches transports below.
|
|
30
|
-
return Boolean(getHostOrigin());
|
|
31
|
-
}
|
|
32
|
-
export function rpc(op, payload) {
|
|
33
|
-
const hostOrigin = getHostOrigin();
|
|
34
|
-
return hostOrigin
|
|
35
|
-
? rpcBridged(op, payload, hostOrigin)
|
|
36
|
-
: rpcStandalone(op, payload);
|
|
37
|
-
}
|
|
38
|
-
// ── URL state (the host address bar as shareable app view-state + screen) ────
|
|
39
|
-
//
|
|
40
|
-
// Two consumers: `useUrlState` (a declared, typed slice of filters/search) and
|
|
41
|
-
// `AppRouter`'s screen mirror (the current screen under `_loc`). Both keep app
|
|
42
|
-
// state in the host's address bar so it survives refresh and is shareable. An
|
|
43
|
-
// embedded app can't touch the cross-origin host URL directly, so reads/writes
|
|
44
|
-
// flow over the bridge: the host writes the params in place (a non-remounting
|
|
45
|
-
// `history.replaceState` — never a navigation, which would reload the iframe) and
|
|
46
|
-
// pushes external changes (browser back/forward) back via the `url-state`
|
|
47
|
-
// broadcast. A standalone app owns its top-level URL and the same ops resolve
|
|
48
|
-
// against `window.location`. (In-app *routing* is not here — the app owns its own
|
|
49
|
-
// url via `@lotics/app-sdk/router`; see that module.)
|
|
50
|
-
/** Read the current app-owned query params — bridged: ask the host; standalone:
|
|
51
|
-
* the page's own query string. */
|
|
52
|
-
export function getUrlParams() {
|
|
53
|
-
return rpc("urlState.get", {});
|
|
54
|
-
}
|
|
55
|
-
/** Merge `patch` into the host's address bar (each key set, or cleared when its
|
|
56
|
-
* value is `undefined`), preserving every other param. The host writes in place
|
|
57
|
-
* (`history.replaceState`) — view-state changes don't add history entries. */
|
|
58
|
-
export function setUrlParams(patch) {
|
|
59
|
-
return rpc("urlState.set", { params: patch });
|
|
60
|
-
}
|
|
61
|
-
/** Synchronous best-effort snapshot for first paint. Standalone reads its own
|
|
62
|
-
* URL (no flash); bridged can't read the cross-origin host URL synchronously,
|
|
63
|
-
* so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
|
|
64
|
-
export function peekUrlParams() {
|
|
65
|
-
return isEmbedded() ? {} : parseSearch(window.location.search);
|
|
66
|
-
}
|
|
67
|
-
/** Subscribe to external query changes — browser back/forward and edited URLs.
|
|
68
|
-
* Embedded: the host's `url-state` broadcast; standalone: `popstate`. */
|
|
69
|
-
export function subscribeUrlParams(cb) {
|
|
70
|
-
if (isEmbedded()) {
|
|
71
|
-
ensureListener();
|
|
72
|
-
urlStateSubscribers.add(cb);
|
|
73
|
-
return () => {
|
|
74
|
-
urlStateSubscribers.delete(cb);
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
const handler = () => cb(parseSearch(window.location.search));
|
|
78
|
-
window.addEventListener("popstate", handler);
|
|
79
|
-
return () => window.removeEventListener("popstate", handler);
|
|
80
|
-
}
|
|
81
|
-
// ── Host notifications (app → host, fire-and-forget) ─────────────────────────
|
|
82
|
-
/**
|
|
83
|
-
* Push a fire-and-forget notification to the embedding host — no id, no reply.
|
|
84
|
-
* Only the embedded host can receive it (it owns the chat surface), so this
|
|
85
|
-
* no-ops standalone (the app's own origin has no host to inform) and never
|
|
86
|
-
* throws. Distinct from `rpc()`: this is one-way, app → host.
|
|
87
|
-
*/
|
|
88
|
-
export function postHostNotification(message) {
|
|
89
|
-
const hostOrigin = getHostOrigin();
|
|
90
|
-
if (!hostOrigin)
|
|
91
|
-
return;
|
|
92
|
-
window.parent.postMessage(message, hostOrigin);
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* Subscribe to the host's `queriesChanged` push, which names the aliases whose
|
|
96
|
-
* underlying tables moved. Returns an unsubscribe fn.
|
|
97
|
-
*
|
|
98
|
-
* The host owns the realtime connection — one per tab, shared by every surface
|
|
99
|
-
* in it — because the app frame deliberately holds no platform credentials, and
|
|
100
|
-
* a second socket per app would only duplicate a subscription the host already
|
|
101
|
-
* has. The host resolves changed tables to aliases (it holds the query ASTs)
|
|
102
|
-
* and names them here, so a mounted hook refetches only when ITS query is
|
|
103
|
-
* affected rather than on every change anywhere in the workspace.
|
|
104
|
-
*
|
|
105
|
-
* A standalone (public) app has no host, so this is inert there — those apps
|
|
106
|
-
* stay on pull-based freshness.
|
|
107
|
-
*/
|
|
108
|
-
export function subscribeQueriesChanged(cb) {
|
|
109
|
-
ensureListener();
|
|
110
|
-
refetchSubscribers.add(cb);
|
|
111
|
-
return () => {
|
|
112
|
-
refetchSubscribers.delete(cb);
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Every alias with a mounted query hook, so a local write can name them.
|
|
117
|
-
*
|
|
118
|
-
* Registered by the hooks themselves rather than derived from the manifest: what
|
|
119
|
-
* matters is what is ON SCREEN, and a screen mounts a handful of an app's
|
|
120
|
-
* queries. Keyed by alias with a count, because two hooks can read one alias at
|
|
121
|
-
* once (a register and the drawer over it) and the first to unmount must not
|
|
122
|
-
* retract the second's registration.
|
|
123
|
-
*/
|
|
124
|
-
const mountedAliases = new Map();
|
|
125
|
-
/** Register a mounted query's alias. Returns the matching unregister. */
|
|
126
|
-
export function registerQueryAlias(alias) {
|
|
127
|
-
mountedAliases.set(alias, (mountedAliases.get(alias) ?? 0) + 1);
|
|
128
|
-
return () => {
|
|
129
|
-
const n = (mountedAliases.get(alias) ?? 1) - 1;
|
|
130
|
-
if (n > 0)
|
|
131
|
-
mountedAliases.set(alias, n);
|
|
132
|
-
else
|
|
133
|
-
mountedAliases.delete(alias);
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
/**
|
|
137
|
-
* Re-read the mounted queries because THIS app just wrote.
|
|
138
|
-
*
|
|
139
|
-
* The host's `queriesChanged` push is for changes the app did not make — it
|
|
140
|
-
* travels the realtime path (a version counter, a socket the host owns, and
|
|
141
|
-
* coalescing that is deliberately lazy under load), which is right for another
|
|
142
|
-
* member's edit and far too slow for your own. A writer already knows, so it
|
|
143
|
-
* rings the same bell locally instead of waiting to be told.
|
|
144
|
-
*
|
|
145
|
-
* It names every mounted alias rather than only the ones the write touched: the
|
|
146
|
-
* app cannot know which tables a workflow wrote, and the alternative — asking
|
|
147
|
-
* every call site to declare what it invalidates — is a list that goes stale
|
|
148
|
-
* silently the first time a workflow body grows a second write. The cost is
|
|
149
|
-
* bounded by what is on screen, and it is the work the app was going to do a
|
|
150
|
-
* moment later anyway.
|
|
151
|
-
*/
|
|
152
|
-
export function notifyLocalWrite() {
|
|
153
|
-
if (mountedAliases.size === 0)
|
|
154
|
-
return;
|
|
155
|
-
const aliases = [...mountedAliases.keys()];
|
|
156
|
-
for (const cb of refetchSubscribers)
|
|
157
|
-
cb(aliases);
|
|
158
|
-
}
|
|
159
|
-
const pending = new Map();
|
|
160
|
-
const streaming = new Map();
|
|
161
|
-
/** `useUrlState` subscribers — notified when the host broadcasts new params
|
|
162
|
-
* after browser back/forward. */
|
|
163
|
-
const urlStateSubscribers = new Set();
|
|
164
|
-
/** Query hooks subscribed to the host's `queriesChanged` push — the host sends
|
|
165
|
-
* it after an ambient app-chat agent turn mutated records. */
|
|
166
|
-
const refetchSubscribers = new Set();
|
|
167
|
-
let nextRpcId = 0;
|
|
168
|
-
let listenerInstalled = false;
|
|
169
|
-
function ensureListener() {
|
|
170
|
-
if (listenerInstalled)
|
|
171
|
-
return;
|
|
172
|
-
listenerInstalled = true;
|
|
173
|
-
window.addEventListener("message", (event) => {
|
|
174
|
-
// Accept only messages from the parent window, at the host origin.
|
|
175
|
-
if (event.source !== window.parent || event.origin !== getHostOrigin())
|
|
176
|
-
return;
|
|
177
|
-
const msg = event.data;
|
|
178
|
-
if (!msg)
|
|
179
|
-
return;
|
|
180
|
-
// Broadcast (no id): the host pushes new params after browser back/forward
|
|
181
|
-
// so `useUrlState` subscribers re-hydrate.
|
|
182
|
-
if (msg.type === "url-state" && msg.params && typeof msg.params === "object") {
|
|
183
|
-
for (const cb of urlStateSubscribers)
|
|
184
|
-
cb(msg.params);
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
// Broadcast (no id): the host names the aliases whose tables changed, from
|
|
188
|
-
// the realtime channel it holds for the whole tab. Push-only freshness; the
|
|
189
|
-
// host never reads app data through this path.
|
|
190
|
-
if (msg.type === "queriesChanged" && Array.isArray(msg.aliases)) {
|
|
191
|
-
const aliases = msg.aliases.filter((a) => typeof a === "string");
|
|
192
|
-
for (const cb of refetchSubscribers)
|
|
193
|
-
cb(aliases);
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
// Broadcast (no id): a recording THIS app started moved — the host sends
|
|
197
|
-
// no other app's and not the chat's.
|
|
198
|
-
if (msg.type === "recordingChanged") {
|
|
199
|
-
const state = readRecordingState(msg.state);
|
|
200
|
-
if (typeof msg.alias !== "string" || state === null) {
|
|
201
|
-
console.error("The host sent a malformed recordingChanged push; it is ignored.", msg);
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
const before = recordingStateOf(msg.alias);
|
|
205
|
-
applyRecordingChange(msg.alias, state);
|
|
206
|
-
// A filed recording is this app's own successful write, so the screen
|
|
207
|
-
// re-reads now, as after any workflow, not on the realtime push.
|
|
208
|
-
const newlyFiled = state.phase === "filed" &&
|
|
209
|
-
!(before.phase === "filed" && before.execution_id === state.execution_id);
|
|
210
|
-
if (newlyFiled)
|
|
211
|
-
notifyLocalWrite();
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
if (typeof msg.id !== "number")
|
|
215
|
-
return;
|
|
216
|
-
// Single-response ops.
|
|
217
|
-
const handler = pending.get(msg.id);
|
|
218
|
-
if (handler) {
|
|
219
|
-
pending.delete(msg.id);
|
|
220
|
-
if (msg.type === "result")
|
|
221
|
-
handler.resolve(msg.data);
|
|
222
|
-
else
|
|
223
|
-
handler.reject(new Error(msg.message ?? "RPC failed"));
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
// Streaming op (agentRun): run-id? chunk* → end | error.
|
|
227
|
-
const stream = streaming.get(msg.id);
|
|
228
|
-
if (!stream)
|
|
229
|
-
return;
|
|
230
|
-
if (msg.type === "run-id") {
|
|
231
|
-
if (typeof msg.runId === "string")
|
|
232
|
-
stream.onRunId?.(msg.runId);
|
|
233
|
-
}
|
|
234
|
-
else if (msg.type === "stream-chunk") {
|
|
235
|
-
if (typeof msg.chunk === "string")
|
|
236
|
-
stream.onText(msg.chunk);
|
|
237
|
-
}
|
|
238
|
-
else if (msg.type === "stream-end") {
|
|
239
|
-
streaming.delete(msg.id);
|
|
240
|
-
stream.resolve();
|
|
241
|
-
}
|
|
242
|
-
else if (msg.type === "error") {
|
|
243
|
-
streaming.delete(msg.id);
|
|
244
|
-
stream.reject(new Error(msg.message ?? "Run failed"));
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
function rpcBridged(op, payload, host) {
|
|
249
|
-
ensureListener();
|
|
250
|
-
return new Promise((resolve, reject) => {
|
|
251
|
-
const id = nextRpcId++;
|
|
252
|
-
pending.set(id, { resolve: resolve, reject });
|
|
253
|
-
window.parent.postMessage({ id, op, payload }, host);
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
// ── Streaming transport (agent runs) ─────────────────────────────────────────
|
|
257
|
-
/**
|
|
258
|
-
* Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
|
|
259
|
-
* (the caller parses it via `agent_stream`); `done` settles when the stream
|
|
260
|
-
* ends. Bridged: the host opens the SSE with its session and forwards chunks;
|
|
261
|
-
* standalone: the SDK reads the public endpoint's body directly.
|
|
262
|
-
*/
|
|
263
|
-
export function rpcAgentRun(payload, onText, onRunId) {
|
|
264
|
-
const hostOrigin = getHostOrigin();
|
|
265
|
-
return hostOrigin
|
|
266
|
-
? agentRunBridged(payload, onText, hostOrigin, onRunId)
|
|
267
|
-
: agentRunStandalone(payload, onText, onRunId);
|
|
268
|
-
}
|
|
269
|
-
function agentRunBridged(payload, onText, host, onRunId) {
|
|
270
|
-
ensureListener();
|
|
271
|
-
const id = nextRpcId++;
|
|
272
|
-
let settleDone = () => { };
|
|
273
|
-
const done = new Promise((resolve, reject) => {
|
|
274
|
-
settleDone = resolve;
|
|
275
|
-
streaming.set(id, { onText, onRunId, resolve, reject });
|
|
276
|
-
window.parent.postMessage({ id, op: "agentRun", payload }, host);
|
|
277
|
-
});
|
|
278
|
-
return {
|
|
279
|
-
done,
|
|
280
|
-
abort: () => {
|
|
281
|
-
if (!streaming.has(id))
|
|
282
|
-
return;
|
|
283
|
-
streaming.delete(id);
|
|
284
|
-
window.parent.postMessage({ id, type: "abort" }, host);
|
|
285
|
-
// The caller stopped the run — resolve `done` cleanly so it never hangs
|
|
286
|
-
// (an abort is not a failure). The host tears down the SSE.
|
|
287
|
-
settleDone();
|
|
288
|
-
},
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
/**
|
|
292
|
-
* Continue a PARKED (`awaiting_input`) agent run with the user's answer to its
|
|
293
|
-
* pending `ask_user_choice` call — streams the continuation leg exactly like
|
|
294
|
-
* `rpcAgentRun` streams the first.
|
|
295
|
-
*/
|
|
296
|
-
export function rpcAgentRunContinue(payload, onText) {
|
|
297
|
-
const hostOrigin = getHostOrigin();
|
|
298
|
-
if (hostOrigin) {
|
|
299
|
-
ensureListener();
|
|
300
|
-
const id = nextRpcId++;
|
|
301
|
-
let settleDone = () => { };
|
|
302
|
-
const done = new Promise((resolve, reject) => {
|
|
303
|
-
settleDone = resolve;
|
|
304
|
-
streaming.set(id, { onText, resolve, reject });
|
|
305
|
-
window.parent.postMessage({ id, op: "agentRunContinue", payload }, hostOrigin);
|
|
306
|
-
});
|
|
307
|
-
return {
|
|
308
|
-
done,
|
|
309
|
-
abort: () => {
|
|
310
|
-
if (!streaming.has(id))
|
|
311
|
-
return;
|
|
312
|
-
streaming.delete(id);
|
|
313
|
-
window.parent.postMessage({ id, type: "abort" }, hostOrigin);
|
|
314
|
-
settleDone();
|
|
315
|
-
},
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
const controller = new AbortController();
|
|
319
|
-
const done = (async () => {
|
|
320
|
-
const { app_id } = await boot();
|
|
321
|
-
const headers = { "content-type": "application/json" };
|
|
322
|
-
if (sessionToken)
|
|
323
|
-
headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
|
|
324
|
-
// Continuing an anonymous parked run is authorized by the same per-run
|
|
325
|
-
// capability that reads it — there is no member to authorize instead.
|
|
326
|
-
const continueToken = runTokens.get(payload.run_id);
|
|
327
|
-
if (continueToken)
|
|
328
|
-
headers[APP_AGENT_RUN_TOKEN_HEADER] = continueToken;
|
|
329
|
-
const res = await fetch(`${apiBase()}/v1/apps/${app_id}/agent-runs/${encodeURIComponent(payload.run_id)}/continue`, {
|
|
330
|
-
method: "POST",
|
|
331
|
-
headers,
|
|
332
|
-
body: JSON.stringify({ tool_call_id: payload.tool_call_id, output: payload.output }),
|
|
333
|
-
signal: controller.signal,
|
|
334
|
-
});
|
|
335
|
-
if (!res.ok || !res.body) {
|
|
336
|
-
throw await streamStartError(res);
|
|
337
|
-
}
|
|
338
|
-
const reader = res.body.getReader();
|
|
339
|
-
const decoder = new TextDecoder();
|
|
340
|
-
try {
|
|
341
|
-
for (;;) {
|
|
342
|
-
const { value, done: streamDone } = await reader.read();
|
|
343
|
-
if (streamDone)
|
|
344
|
-
break;
|
|
345
|
-
onText(decoder.decode(value, { stream: true }));
|
|
346
|
-
}
|
|
347
|
-
const tail = decoder.decode();
|
|
348
|
-
if (tail)
|
|
349
|
-
onText(tail);
|
|
350
|
-
}
|
|
351
|
-
catch (err) {
|
|
352
|
-
if (controller.signal.aborted)
|
|
353
|
-
return;
|
|
354
|
-
throw err;
|
|
355
|
-
}
|
|
356
|
-
})();
|
|
357
|
-
return { done, abort: () => controller.abort() };
|
|
358
|
-
}
|
|
359
|
-
function agentRunStandalone(payload, onText, onRunId) {
|
|
360
|
-
const controller = new AbortController();
|
|
361
|
-
const done = (async () => {
|
|
362
|
-
const { app_id } = await boot();
|
|
363
|
-
const headers = { "content-type": "application/json" };
|
|
364
|
-
if (sessionToken)
|
|
365
|
-
headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
|
|
366
|
-
const res = await fetch(`${apiBase()}/v1/apps/${app_id}/agents/${encodeURIComponent(payload.alias)}/runs`, {
|
|
367
|
-
method: "POST",
|
|
368
|
-
headers,
|
|
369
|
-
body: JSON.stringify({ session_id: payload.session_id, input: payload.input }),
|
|
370
|
-
signal: controller.signal,
|
|
371
|
-
});
|
|
372
|
-
if (!res.ok || !res.body) {
|
|
373
|
-
throw await streamStartError(res);
|
|
374
|
-
}
|
|
375
|
-
const runId = res.headers.get("x-app-agent-run-id");
|
|
376
|
-
if (runId) {
|
|
377
|
-
// Anonymous runs carry their retrieval capability here; a member run does
|
|
378
|
-
// not, and `rememberRunToken` no-ops on the null.
|
|
379
|
-
rememberRunToken(runId, res.headers.get(APP_AGENT_RUN_TOKEN_HEADER));
|
|
380
|
-
onRunId?.(runId);
|
|
381
|
-
}
|
|
382
|
-
const reader = res.body.getReader();
|
|
383
|
-
const decoder = new TextDecoder();
|
|
384
|
-
try {
|
|
385
|
-
for (;;) {
|
|
386
|
-
const { value, done: streamDone } = await reader.read();
|
|
387
|
-
if (streamDone)
|
|
388
|
-
break;
|
|
389
|
-
onText(decoder.decode(value, { stream: true }));
|
|
390
|
-
}
|
|
391
|
-
const tail = decoder.decode(); // flush any bytes held across the final read
|
|
392
|
-
if (tail)
|
|
393
|
-
onText(tail);
|
|
394
|
-
}
|
|
395
|
-
catch (err) {
|
|
396
|
-
if (controller.signal.aborted)
|
|
397
|
-
return; // the caller stopped the run — clean, not an error
|
|
398
|
-
throw err;
|
|
399
|
-
}
|
|
400
|
-
})();
|
|
401
|
-
return { done, abort: () => controller.abort() };
|
|
402
|
-
}
|
|
403
|
-
// ── Standalone transport ────────────────────────────────────────────────────
|
|
404
|
-
/**
|
|
405
|
-
* Meta the host serving a standalone bundle injects into `index.html`, naming
|
|
406
|
-
* the API the app calls.
|
|
407
|
-
*
|
|
408
|
-
* It is the serving host's to state, not the SDK's to assume: the same bundle
|
|
409
|
-
* is served by whoever runs the instance, and an app that carried a compiled-in
|
|
410
|
-
* API address would call somebody else's server. A bridged app never reads it —
|
|
411
|
-
* there the host's own origin arrives on `?lotics_host=`.
|
|
412
|
-
*/
|
|
413
|
-
const API_BASE_META = "lotics-api-base";
|
|
414
|
-
let apiBaseCache = null;
|
|
415
|
-
function apiBase() {
|
|
416
|
-
if (apiBaseCache !== null)
|
|
417
|
-
return apiBaseCache;
|
|
418
|
-
const content = document
|
|
419
|
-
.querySelector(`meta[name="${API_BASE_META}"]`)
|
|
420
|
-
?.getAttribute("content")
|
|
421
|
-
?.trim();
|
|
422
|
-
if (!content) {
|
|
423
|
-
// No default: guessing would send this app's data to whatever address the
|
|
424
|
-
// guess named. Say what is missing and who puts it there.
|
|
425
|
-
throw new Error(`This app has no API address. A standalone app reads it from <meta name="${API_BASE_META}"> ` +
|
|
426
|
-
"in the page the app host serves, and this page carries none.");
|
|
427
|
-
}
|
|
428
|
-
apiBaseCache = content.replace(/\/+$/, "");
|
|
429
|
-
return apiBaseCache;
|
|
430
|
-
}
|
|
431
|
-
const PASSWORD_REQUIRED_CODE = "PASSWORD_REQUIRED";
|
|
432
|
-
/**
|
|
433
|
-
* Boot-time resolution result. Promise is shared so concurrent first calls
|
|
434
|
-
* (e.g. two `useQuery` hooks rendering simultaneously) coalesce into one
|
|
435
|
-
* `/by-subdomain` fetch and at most one password prompt.
|
|
436
|
-
*/
|
|
437
|
-
let bootPromise = null;
|
|
438
|
-
let appInfoPromise = null;
|
|
439
|
-
let sessionToken = null;
|
|
440
|
-
/**
|
|
441
|
-
* The password-session header. It is deliberately NOT `Authorization`: this
|
|
442
|
-
* token authenticates nobody — it proves the visitor knows the app's shared
|
|
443
|
-
* link password — and the credential header already carries API keys and OAuth
|
|
444
|
-
* bearers. Sharing it meant the server's auth middleware rejected the request
|
|
445
|
-
* as an unknown bearer before the password gate ever ran, which took every
|
|
446
|
-
* password-gated public app offline. Wire constant, mirrored server-side by
|
|
447
|
-
* `APP_PUBLIC_SESSION_HEADER`; both sides are pinned by tests.
|
|
448
|
-
*/
|
|
449
|
-
export const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
|
|
450
|
-
/**
|
|
451
|
-
* Per-run retrieval credentials for ANONYMOUS agent runs, keyed by run id.
|
|
452
|
-
*
|
|
453
|
-
* A member's run is authorized by their identity, so reading it back needs
|
|
454
|
-
* nothing extra. An anonymous run on a publicly-shared app has no member to key
|
|
455
|
-
* on (`triggered_by_member_id` is null by design), so the server mints a
|
|
456
|
-
* capability token bound to that one run and returns it on the run response.
|
|
457
|
-
* Without it the recovery poll 404s and a completed answer is lost to any
|
|
458
|
-
* dropped connection — routine on mobile for a run measured in tens of seconds.
|
|
459
|
-
*
|
|
460
|
-
* Transport-level, like `sessionToken` above and for the same reason: it is a
|
|
461
|
-
* credential, not app state. Threading it through the hook would put a bearer
|
|
462
|
-
* into the app's typed surface for every app to forward by hand.
|
|
463
|
-
*
|
|
464
|
-
* Only the STANDALONE transport needs this — an embedded app always runs under
|
|
465
|
-
* a member session, so it never produces an anonymous run.
|
|
466
|
-
*/
|
|
467
|
-
const runTokens = new Map();
|
|
468
|
-
/** Bound the map so a long-lived page cannot accumulate tokens without limit.
|
|
469
|
-
* Insertion-ordered, so the oldest entry is the first key. */
|
|
470
|
-
const MAX_TRACKED_RUN_TOKENS = 8;
|
|
471
|
-
function rememberRunToken(runId, token) {
|
|
472
|
-
if (!token)
|
|
473
|
-
return;
|
|
474
|
-
runTokens.set(runId, token);
|
|
475
|
-
while (runTokens.size > MAX_TRACKED_RUN_TOKENS) {
|
|
476
|
-
const oldest = runTokens.keys().next();
|
|
477
|
-
if (oldest.done)
|
|
478
|
-
break;
|
|
479
|
-
runTokens.delete(oldest.value);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
/** The run token header — mirrored server-side by `APP_AGENT_RUN_TOKEN_HEADER`. */
|
|
483
|
-
export const APP_AGENT_RUN_TOKEN_HEADER = "x-app-agent-run-token";
|
|
484
|
-
/**
|
|
485
|
-
* The cookie the app-host Worker sets after the visitor clears the password
|
|
486
|
-
* gate. Readable by design — the SDK forwards its value as the session header
|
|
487
|
-
* on every data call. Same exposure as the `localStorage` token it replaced,
|
|
488
|
-
* minus the password prompt the SDK no longer owns.
|
|
489
|
-
*/
|
|
490
|
-
const SESSION_COOKIE = "lotics_app_session";
|
|
491
|
-
function readSessionCookie() {
|
|
492
|
-
for (const part of document.cookie.split(";")) {
|
|
493
|
-
const eq = part.indexOf("=");
|
|
494
|
-
if (eq < 0)
|
|
495
|
-
continue;
|
|
496
|
-
if (part.slice(0, eq).trim() === SESSION_COOKIE) {
|
|
497
|
-
return decodeURIComponent(part.slice(eq + 1).trim()) || null;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
return null;
|
|
501
|
-
}
|
|
502
|
-
/**
|
|
503
|
-
* Resolve the app's identity from its own subdomain. Shared
|
|
504
|
-
* promise so the context bootstrap and the first data call coalesce into one
|
|
505
|
-
* `/by-subdomain` fetch. Does NOT touch the password gate — the app's own
|
|
506
|
-
* identity is not the gated thing, so a password-gated app still resolves it
|
|
507
|
-
* before the prompt.
|
|
508
|
-
*/
|
|
509
|
-
function resolveAppInfo() {
|
|
510
|
-
if (appInfoPromise)
|
|
511
|
-
return appInfoPromise;
|
|
512
|
-
const slug = window.location.hostname.split(".")[0];
|
|
513
|
-
const attempt = apiCall("GET", `/v1/apps/by-subdomain/${encodeURIComponent(slug)}`);
|
|
514
|
-
// Don't cache a rejection — a transient failure on first load would brick
|
|
515
|
-
// every later data call. Clear the slot so the next call retries.
|
|
516
|
-
attempt.catch(() => {
|
|
517
|
-
if (appInfoPromise === attempt)
|
|
518
|
-
appInfoPromise = null;
|
|
519
|
-
});
|
|
520
|
-
appInfoPromise = attempt;
|
|
521
|
-
return attempt;
|
|
522
|
-
}
|
|
523
|
-
async function boot() {
|
|
524
|
-
if (bootPromise)
|
|
525
|
-
return bootPromise;
|
|
526
|
-
const attempt = (async () => {
|
|
527
|
-
const info = await resolveAppInfo();
|
|
528
|
-
if (info.requires_password) {
|
|
529
|
-
// The visitor already passed the gate — the app-host Worker refuses to
|
|
530
|
-
// serve a single byte of this bundle otherwise — and handed us the session
|
|
531
|
-
// in a cookie on this origin. The SDK does not prompt: a password screen
|
|
532
|
-
// is the serving layer's job, which is why it can be one styled, localized
|
|
533
|
-
// page for every public app instead of hand-rolled DOM in a data library.
|
|
534
|
-
sessionToken = readSessionCookie();
|
|
535
|
-
}
|
|
536
|
-
return info;
|
|
537
|
-
})();
|
|
538
|
-
attempt.catch(() => {
|
|
539
|
-
if (bootPromise === attempt)
|
|
540
|
-
bootPromise = null;
|
|
541
|
-
});
|
|
542
|
-
bootPromise = attempt;
|
|
543
|
-
return attempt;
|
|
544
|
-
}
|
|
545
|
-
/**
|
|
546
|
-
* A user-facing message for a transport/gateway failure — derived from the HTTP
|
|
547
|
-
* status, never from the response body. A 524 (Cloudflare edge timeout on a long
|
|
548
|
-
* run), any 5xx, or a non-JSON body (an HTML error page) must NOT surface its raw
|
|
549
|
-
* body as the error message.
|
|
550
|
-
*
|
|
551
|
-
* A by-value MIRROR of `@lotics/shared/transport_error`, which is canonical. This
|
|
552
|
-
* package ships to npm with zero internal dependencies, so it cannot import it;
|
|
553
|
-
* every other transport does. Change one, change both.
|
|
554
|
-
*/
|
|
555
|
-
function gatewayErrorMessage(status) {
|
|
556
|
-
if (status === 524) {
|
|
557
|
-
return "The request took too long to finish (gateway timeout). It may still be running — check back in a moment, or try again.";
|
|
558
|
-
}
|
|
559
|
-
if (status >= 500) {
|
|
560
|
-
return "The service is temporarily unavailable. Please try again shortly.";
|
|
561
|
-
}
|
|
562
|
-
return "The service returned an unexpected response. Please try again.";
|
|
563
|
-
}
|
|
564
|
-
/**
|
|
565
|
-
* The error message for a non-ok response. A JSON error the API AUTHORED surfaces
|
|
566
|
-
* verbatim — a 4xx carrying a `message`, or a 5xx that also carries `code`, the
|
|
567
|
-
* discriminator every error the API emits is built with. A non-JSON body (a
|
|
568
|
-
* gateway HTML page), a 5xx from something that is not us, or a body without a
|
|
569
|
-
* `message` falls back to a body-free, status-derived message — so a raw HTML
|
|
570
|
-
* body never becomes the message. `parsed` is the JSON.parse of the body, or
|
|
571
|
-
* `null` if it wasn't JSON.
|
|
572
|
-
*/
|
|
573
|
-
export function transportErrorMessage(status, parsed) {
|
|
574
|
-
const jsonMessage = parsed && typeof parsed.message === "string"
|
|
575
|
-
? parsed.message
|
|
576
|
-
: null;
|
|
577
|
-
if (jsonMessage === null)
|
|
578
|
-
return gatewayErrorMessage(status);
|
|
579
|
-
// Below 500 the body is ours. At 500 and above it is ours only if it carries
|
|
580
|
-
// `code` — the discriminator every error the API emits is built with. Status
|
|
581
|
-
// alone was the wrong test: it threw away the 503 our OWN backpressure gate
|
|
582
|
-
// authors and replaced it with a different English sentence, so no wording we
|
|
583
|
-
// choose for a 5xx could ever reach an app member (GAP-300). A gateway's HTML
|
|
584
|
-
// does not parse at all, and a proxy's JSON does not carry `code`, so both
|
|
585
|
-
// still get the body-free message.
|
|
586
|
-
const authored = typeof parsed.code === "string" &&
|
|
587
|
-
parsed.code.length > 0;
|
|
588
|
-
return status < 500 || authored ? jsonMessage : gatewayErrorMessage(status);
|
|
589
|
-
}
|
|
590
|
-
/**
|
|
591
|
-
* A failed request, carrying the per-input refusals when the API named any.
|
|
592
|
-
*
|
|
593
|
-
* A body that does not match what an alias declares is answered 400 with
|
|
594
|
-
* top-level `field_errors` — one sentence per INPUT name, which is what a form
|
|
595
|
-
* puts on the control that is wrong, where the message can only say it at the
|
|
596
|
-
* screen's scope. It rides the error because the transport's own callers are
|
|
597
|
-
* what turn a failure into the result an app reads. Optional throughout: a
|
|
598
|
-
* server that predates them sends none.
|
|
599
|
-
*/
|
|
600
|
-
class ApiRefusal extends Error {
|
|
601
|
-
field_errors;
|
|
602
|
-
constructor(message, field_errors) {
|
|
603
|
-
super(message);
|
|
604
|
-
this.field_errors = field_errors;
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
/** The `field_errors` of an error body, when it carries a well-formed one. */
|
|
608
|
-
function readFieldErrors(parsed) {
|
|
609
|
-
if (parsed === null || typeof parsed !== "object" || !("field_errors" in parsed)) {
|
|
610
|
-
return undefined;
|
|
611
|
-
}
|
|
612
|
-
const raw = parsed.field_errors;
|
|
613
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
614
|
-
return undefined;
|
|
615
|
-
const named = Object.entries(raw).filter((entry) => typeof entry[1] === "string");
|
|
616
|
-
return named.length > 0 ? Object.fromEntries(named) : undefined;
|
|
617
|
-
}
|
|
618
|
-
/**
|
|
619
|
-
* The error a stream that never started should throw.
|
|
620
|
-
*
|
|
621
|
-
* A streaming endpoint fails BEFORE the first byte like any other request — a 402 when the
|
|
622
|
-
* workspace is out of credits, a 403, a 404. Those bodies are structured errors, and throwing
|
|
623
|
-
* the body TEXT put the whole JSON on screen: `{"message":"Credit quota exceeded…","error":
|
|
624
|
-
* "quota_exceeded","plan_id":"free",…}`. Same rule as every other call: the `message` is the
|
|
625
|
-
* message, and a non-JSON or 5xx body never becomes one.
|
|
626
|
-
*/
|
|
627
|
-
export async function streamStartError(res) {
|
|
628
|
-
const text = await res.text().catch(() => "");
|
|
629
|
-
let parsed = null;
|
|
630
|
-
if (text) {
|
|
631
|
-
try {
|
|
632
|
-
parsed = JSON.parse(text);
|
|
633
|
-
}
|
|
634
|
-
catch {
|
|
635
|
-
// not JSON — a gateway HTML page; transportErrorMessage will not surface it
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
return new Error(transportErrorMessage(res.status, parsed));
|
|
639
|
-
}
|
|
640
|
-
// Standalone requests carried no timeout: a hung or slow backend pinned the
|
|
641
|
-
// fetch — and one of the browser's few per-origin connections — indefinitely,
|
|
642
|
-
// surfacing as an app that "loads forever". Bound every standalone call the way
|
|
643
|
-
// the bridged host path already bounds its own (`apiFetch`, 30s): a stalled
|
|
644
|
-
// request fails fast with a gateway-timeout message instead of spinning.
|
|
645
|
-
const API_TIMEOUT_MS = 30_000;
|
|
646
|
-
async function apiCall(method, path, body, opts) {
|
|
647
|
-
const headers = {};
|
|
648
|
-
if (body)
|
|
649
|
-
headers["content-type"] = "application/json";
|
|
650
|
-
if (sessionToken && !opts?.skipAuth) {
|
|
651
|
-
headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
|
|
652
|
-
}
|
|
653
|
-
// The per-run capability for an ANONYMOUS run — the only thing that authorizes
|
|
654
|
-
// reading or cancelling it, since there is no member identity to check.
|
|
655
|
-
if (opts?.runToken) {
|
|
656
|
-
headers[APP_AGENT_RUN_TOKEN_HEADER] = opts.runToken;
|
|
657
|
-
}
|
|
658
|
-
const controller = new AbortController();
|
|
659
|
-
let didTimeout = false;
|
|
660
|
-
const timeoutId = setTimeout(() => {
|
|
661
|
-
didTimeout = true;
|
|
662
|
-
controller.abort();
|
|
663
|
-
}, API_TIMEOUT_MS);
|
|
664
|
-
let res;
|
|
665
|
-
try {
|
|
666
|
-
res = await fetch(`${apiBase()}${path}`, {
|
|
667
|
-
method,
|
|
668
|
-
headers,
|
|
669
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
670
|
-
signal: controller.signal,
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
|
-
catch (err) {
|
|
674
|
-
// A timeout abort surfaces as an AbortError — convert it to the same
|
|
675
|
-
// body-free gateway-timeout message a 524 produces, never a raw abort.
|
|
676
|
-
if (didTimeout && err instanceof DOMException && err.name === "AbortError") {
|
|
677
|
-
throw new Error(gatewayErrorMessage(524));
|
|
678
|
-
}
|
|
679
|
-
throw err;
|
|
680
|
-
}
|
|
681
|
-
finally {
|
|
682
|
-
clearTimeout(timeoutId);
|
|
683
|
-
}
|
|
684
|
-
const text = await res.text();
|
|
685
|
-
let parsed = null;
|
|
686
|
-
if (text) {
|
|
687
|
-
try {
|
|
688
|
-
parsed = JSON.parse(text);
|
|
689
|
-
}
|
|
690
|
-
catch {
|
|
691
|
-
// not JSON — body left as raw text
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
if (!res.ok) {
|
|
695
|
-
const errorBody = parsed;
|
|
696
|
-
// The session went stale — the owner rotated or cleared the password, so the
|
|
697
|
-
// token's fingerprint no longer matches. Reloading is the whole recovery:
|
|
698
|
-
// the app-host Worker re-checks its own cookie and, if that is stale too,
|
|
699
|
-
// serves the gate. The SDK deliberately owns no re-prompt — the gate is a
|
|
700
|
-
// page at the serving layer, not a modal inside the data layer.
|
|
701
|
-
if (res.status === 401 && errorBody?.error_code === PASSWORD_REQUIRED_CODE && !opts?.skipAuth) {
|
|
702
|
-
sessionToken = null;
|
|
703
|
-
// `/__gate`, not a plain reload: the edge cookie outlives the API session
|
|
704
|
-
// (rotation kills the session immediately, the cookie only on expiry), so
|
|
705
|
-
// reloading would be served the bundle again and loop forever. This route
|
|
706
|
-
// clears both cookies and re-presents the gate.
|
|
707
|
-
window.location.assign("/__gate");
|
|
708
|
-
// Never resolves — navigation replaces the document. Returning would let
|
|
709
|
-
// callers render an error state during teardown.
|
|
710
|
-
return new Promise(() => { });
|
|
711
|
-
}
|
|
712
|
-
// Never surface a non-JSON body (a gateway HTML error page) or a 5xx body as
|
|
713
|
-
// the message — emit a body-free, status-derived message instead. The
|
|
714
|
-
// per-input refusals ride along, for the caller that can place them.
|
|
715
|
-
throw new ApiRefusal(transportErrorMessage(res.status, parsed), readFieldErrors(parsed));
|
|
716
|
-
}
|
|
717
|
-
return parsed ?? (text ? text : {});
|
|
718
|
-
}
|
|
719
|
-
function rpcStandalone(op, payload) {
|
|
720
|
-
switch (op) {
|
|
721
|
-
case "query":
|
|
722
|
-
return standaloneQuery(payload);
|
|
723
|
-
case "field_options":
|
|
724
|
-
return standaloneFieldOptions(payload);
|
|
725
|
-
case "workflow":
|
|
726
|
-
return standaloneWorkflow(payload);
|
|
727
|
-
case "agentRuns":
|
|
728
|
-
return standaloneAgentRuns(payload);
|
|
729
|
-
case "agentRun.get":
|
|
730
|
-
return standaloneAgentRunGet(payload);
|
|
731
|
-
case "agentRun.cancel":
|
|
732
|
-
return standaloneAgentRunCancel(payload);
|
|
733
|
-
case "upload":
|
|
734
|
-
return standaloneUpload(payload.file, payload.fidelity);
|
|
735
|
-
case "members":
|
|
736
|
-
return standaloneMembers(payload);
|
|
737
|
-
case "context":
|
|
738
|
-
return standaloneContext();
|
|
739
|
-
case "openExternal":
|
|
740
|
-
return standaloneOpenExternal(payload);
|
|
741
|
-
case "openApp":
|
|
742
|
-
// A standalone app is one page at its own address: no host routing
|
|
743
|
-
// between apps, no sibling to reach.
|
|
744
|
-
return Promise.reject(new Error("openApp needs the Lotics host: a standalone app has no sibling apps to open. Gate the control on isEmbedded()."));
|
|
745
|
-
case "askAi":
|
|
746
|
-
// The chat surface lives in the Lotics host — a standalone page has
|
|
747
|
-
// nowhere to hand off to.
|
|
748
|
-
return Promise.reject(new Error("askAi is only available when the app runs inside Lotics"));
|
|
749
|
-
case "urlState.get":
|
|
750
|
-
// Standalone is a top-level page — its own query string IS the store.
|
|
751
|
-
return Promise.resolve(parseSearch(window.location.search));
|
|
752
|
-
case "urlState.set":
|
|
753
|
-
return standaloneUrlStateSet(payload);
|
|
754
|
-
case "comments.list":
|
|
755
|
-
case "comments.create":
|
|
756
|
-
case "comments.update":
|
|
757
|
-
case "comments.delete":
|
|
758
|
-
case "comments.counts":
|
|
759
|
-
return rejectCommentsStandalone();
|
|
760
|
-
case "recording.start":
|
|
761
|
-
case "recording.stop":
|
|
762
|
-
// The host records, as the signed-in member; a standalone visitor is
|
|
763
|
-
// anonymous and has no host to capture in.
|
|
764
|
-
return Promise.reject(new Error("Recording needs the Lotics host and a signed-in member. Gate the control on useRecording's `available`."));
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
/**
|
|
768
|
-
* Comments are a members-only collaboration surface. A comment must have an
|
|
769
|
-
* authenticated member as its author, and a thread can carry member-to-member
|
|
770
|
-
* discussion — so a standalone (public, anonymous) app has no member to author
|
|
771
|
-
* as and no business reading other members' threads. `useComments` detects this
|
|
772
|
-
* up front via the null context `member_id` and never sends a comment op; this
|
|
773
|
-
* rejection is the belt-and-braces backstop for any direct `rpc()` caller.
|
|
774
|
-
*/
|
|
775
|
-
function rejectCommentsStandalone() {
|
|
776
|
-
return Promise.reject(new Error("Comments are available only in embedded apps — a signed-in member is required."));
|
|
777
|
-
}
|
|
778
|
-
async function standaloneMembers(p) {
|
|
779
|
-
const { app_id } = await boot();
|
|
780
|
-
const qs = p.group ? `?group_id=${encodeURIComponent(p.group)}` : "";
|
|
781
|
-
const r = (await apiCall("GET", `/v1/apps/${app_id}/members${qs}`, undefined, {
|
|
782
|
-
appId: app_id,
|
|
783
|
-
}));
|
|
784
|
-
return { members: r.members ?? [] };
|
|
785
|
-
}
|
|
786
|
-
/**
|
|
787
|
-
* Open an external URL in a new tab, scheme-validated. In standalone mode the
|
|
788
|
-
* app is a normal top-level page on its own origin, so `window.open` is not
|
|
789
|
-
* sandbox-blocked — open directly. (Bridged apps route this op to the host,
|
|
790
|
-
* which opens it in the un-sandboxed parent frame; see `app_iframe_host`.)
|
|
791
|
-
* The scheme is re-validated wherever the open actually happens — never trust a
|
|
792
|
-
* URL handed across the bridge.
|
|
793
|
-
*/
|
|
794
|
-
function openValidatedUrl(url) {
|
|
795
|
-
if (typeof url !== "string")
|
|
796
|
-
throw new Error("openExternal requires a url string");
|
|
797
|
-
const parsed = new URL(url);
|
|
798
|
-
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
799
|
-
throw new Error(`openExternal: unsupported URL scheme "${parsed.protocol}"`);
|
|
800
|
-
}
|
|
801
|
-
window.open(parsed.href, "_blank", "noopener,noreferrer");
|
|
802
|
-
}
|
|
803
|
-
// `async` so a synchronous validation throw surfaces as a rejected Promise.
|
|
804
|
-
async function standaloneOpenExternal(p) {
|
|
805
|
-
openValidatedUrl(p.url);
|
|
806
|
-
}
|
|
807
|
-
async function standaloneUrlStateSet(p) {
|
|
808
|
-
const search = serializeMerge(window.location.search, p.params ?? {});
|
|
809
|
-
const url = window.location.pathname + (search ? `?${search}` : "") + window.location.hash;
|
|
810
|
-
// View-state writes never add a history entry; replaceState doesn't fire
|
|
811
|
-
// popstate, so there's no echo — the caller already updated optimistically.
|
|
812
|
-
window.history.replaceState(null, "", url);
|
|
813
|
-
}
|
|
814
|
-
async function standaloneContext() {
|
|
815
|
-
const info = await resolveAppInfo();
|
|
816
|
-
return {
|
|
817
|
-
// No host session in standalone mode — the visitor is anonymous, so
|
|
818
|
-
// comments are unavailable regardless of the capability flag.
|
|
819
|
-
member_id: null,
|
|
820
|
-
comments_enabled: info.comments_enabled,
|
|
821
|
-
recording_enabled: false,
|
|
822
|
-
};
|
|
823
|
-
}
|
|
824
|
-
/**
|
|
825
|
-
* The op payload IS the endpoint's body, and the endpoint's answer IS the op's
|
|
826
|
-
* result: both pass through untouched, so `sort`/`filter`/`count`/`keyset` on
|
|
827
|
-
* the way out and `total`/`truncated`/`next_cursor` on the way back reach a
|
|
828
|
-
* standalone app exactly as they reach a bridged one. Naming fields here is
|
|
829
|
-
* what once made `truncated` a host-only field.
|
|
830
|
-
*/
|
|
831
|
-
async function standaloneQuery(payload) {
|
|
832
|
-
const { app_id } = await boot();
|
|
833
|
-
return apiCall("POST", `/v1/apps/${app_id}/query`, payload, { appId: app_id });
|
|
834
|
-
}
|
|
835
|
-
async function standaloneFieldOptions(p) {
|
|
836
|
-
const { app_id } = await boot();
|
|
837
|
-
const r = (await apiCall("POST", `/v1/apps/${app_id}/field-options`, { alias: p.alias }, { appId: app_id }));
|
|
838
|
-
return { fields: r.fields ?? {} };
|
|
839
|
-
}
|
|
840
|
-
async function standaloneWorkflow(p) {
|
|
841
|
-
const { app_id } = await boot();
|
|
842
|
-
try {
|
|
843
|
-
return await apiCall("POST", `/v1/apps/${app_id}/workflows/${encodeURIComponent(p.alias)}/execute`, { inputs: p.inputs }, { appId: app_id });
|
|
844
|
-
}
|
|
845
|
-
catch (err) {
|
|
846
|
-
// A transport/gateway failure resolves to a WorkflowResult error (never a
|
|
847
|
-
// rejection carrying a raw body) so an app reads `result.status === "error"`
|
|
848
|
-
// uniformly with a handled workflow error. `apiCall` already sanitized the
|
|
849
|
-
// message, so it never contains an HTML body.
|
|
850
|
-
//
|
|
851
|
-
// A payload the alias refuses is that same shape plus the inputs it named,
|
|
852
|
-
// which is the half a form can act on — the same key a workflow's own
|
|
853
|
-
// `return({ field_errors })` arrives under, so a screen wires one control
|
|
854
|
-
// once whichever half answered.
|
|
855
|
-
return {
|
|
856
|
-
status: "error",
|
|
857
|
-
message: err instanceof Error ? err.message : "The workflow failed to run.",
|
|
858
|
-
...(err instanceof ApiRefusal && err.field_errors ? { field_errors: err.field_errors } : {}),
|
|
859
|
-
};
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
async function standaloneAgentRuns(p) {
|
|
863
|
-
const { app_id } = await boot();
|
|
864
|
-
const qs = new URLSearchParams({ session_id: p.session_id });
|
|
865
|
-
if (p.limit != null)
|
|
866
|
-
qs.set("limit", String(p.limit));
|
|
867
|
-
if (p.offset != null)
|
|
868
|
-
qs.set("offset", String(p.offset));
|
|
869
|
-
const r = (await apiCall("GET", `/v1/apps/${app_id}/agent-runs?${qs.toString()}`, undefined, {
|
|
870
|
-
appId: app_id,
|
|
871
|
-
}));
|
|
872
|
-
return { runs: r.runs ?? [] };
|
|
873
|
-
}
|
|
874
|
-
/** Poll read for a single run — follows a run to completion after its stream
|
|
875
|
-
* connection drops. The caller types the run shape via `rpc<T>`. */
|
|
876
|
-
async function standaloneAgentRunGet(p) {
|
|
877
|
-
const { app_id } = await boot();
|
|
878
|
-
const r = (await apiCall("GET", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}`, undefined, {
|
|
879
|
-
appId: app_id,
|
|
880
|
-
runToken: runTokens.get(p.run_id),
|
|
881
|
-
}));
|
|
882
|
-
return { run: r.run };
|
|
883
|
-
}
|
|
884
|
-
/** Request server-side cancellation of an in-flight run (an explicit user stop,
|
|
885
|
-
* distinct from merely closing the stream). */
|
|
886
|
-
async function standaloneAgentRunCancel(p) {
|
|
887
|
-
const { app_id } = await boot();
|
|
888
|
-
await apiCall("POST", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}/cancel`, {}, {
|
|
889
|
-
appId: app_id,
|
|
890
|
-
runToken: runTokens.get(p.run_id),
|
|
891
|
-
});
|
|
892
|
-
return { ok: true };
|
|
893
|
-
}
|
|
894
|
-
async function standaloneUpload(file, fidelity) {
|
|
895
|
-
if (!(file instanceof File)) {
|
|
896
|
-
throw new Error("upload payload must include a File");
|
|
897
|
-
}
|
|
898
|
-
const { app_id } = await boot();
|
|
899
|
-
const uploaded = await runUploadPipeline(file, {
|
|
900
|
-
initUpload: (input) => apiCall("POST", `/v1/apps/${app_id}/files/upload-url`, input, { appId: app_id }),
|
|
901
|
-
completeUpload: (input) => apiCall("POST", `/v1/apps/${app_id}/files/complete`, input, { appId: app_id }),
|
|
902
|
-
}, { fidelity });
|
|
903
|
-
return uploaded;
|
|
904
|
-
}
|