@lotics/app-sdk 0.34.0 → 0.35.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 +262 -0
- package/dist/src/agent_stream.d.ts +55 -0
- package/dist/src/agent_stream.js +78 -0
- package/dist/src/hooks.d.ts +72 -2
- package/dist/src/hooks.js +105 -2
- package/dist/src/index.d.ts +3 -3
- package/dist/src/index.js +1 -1
- package/dist/src/rpc.d.ts +20 -1
- package/dist/src/rpc.js +108 -6
- package/dist/src/types.d.ts +25 -0
- package/package.json +2 -2
package/AGENTS.md
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# @lotics/app-sdk — the SDK reference
|
|
2
|
+
|
|
3
|
+
The **data + RPC** runtime for Lotics custom-code apps: typed React hooks over an
|
|
4
|
+
origin-locked postMessage bridge, plus cell decoders and the `mount()` entry point. This
|
|
5
|
+
file is the SDK reference — the "reach for what" guide + the load-bearing patterns. The
|
|
6
|
+
**exact signature** of any hook or reader is its source, which ships with the package: read
|
|
7
|
+
`node_modules/@lotics/app-sdk/dist/src/<name>.d.ts`. **Never guess a signature — open the
|
|
8
|
+
file.**
|
|
9
|
+
|
|
10
|
+
- Import from the package root: `import { useQuery, useWorkflow, row } from "@lotics/app-sdk"`.
|
|
11
|
+
- **Data + RPC only — no UI.** The SDK ships zero components (keeps it off the React-Native-Web
|
|
12
|
+
tree). Render with **`@lotics/ui`** (read its `node_modules/@lotics/ui/AGENTS.md`); the two
|
|
13
|
+
pair — SDK fetches/mutates, `@lotics/ui` draws.
|
|
14
|
+
- **Two transports, picked automatically.** Embedded (the authenticated product host bridges
|
|
15
|
+
RPC over postMessage) vs. standalone (`<slug>.lotics.app`, public, calls the API directly).
|
|
16
|
+
App code never branches on it; the SDK does.
|
|
17
|
+
- **The app holds no credentials and writes nothing directly.** Every read is a declared named
|
|
18
|
+
query; every write is a declared workflow. The model, the IAM/auth rules, and the security
|
|
19
|
+
rationale live in **`docs/apps.md`** — read it for *why*; this file is *how*.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Reach by role — the surface
|
|
24
|
+
|
|
25
|
+
Pick by intent. (→ open the `.d.ts` for the exact signature.)
|
|
26
|
+
|
|
27
|
+
- **Read rows** — three hooks, one job each; don't overload one:
|
|
28
|
+
- **`useQuery(alias, params?, opts?)`** — a single fetch (`pageSize` is a cap, not pagination).
|
|
29
|
+
For a detail read or a combobox's top-N. `opts.enabled` gates the fetch (nothing loads until
|
|
30
|
+
true); `opts.sort` / `opts.filter` refine over the query's OUTPUT columns.
|
|
31
|
+
- **`useInfiniteQuery`** — append / load-more (`loadMore`, accumulating `rows`). Infinite scroll.
|
|
32
|
+
- **`usePaginatedQuery(alias, params?, { pageSize, sort, filter })`** — page-model with a
|
|
33
|
+
`total`, behind `Pagination`. Owns the page cursor; fetches the page AND a `count` keyed on
|
|
34
|
+
`(alias, params, filter)` (independent of page+sort, so paging/re-sorting never recount).
|
|
35
|
+
`(params, filter)` is the result-set identity — changing it resets to page 0 and recounts.
|
|
36
|
+
- **A select field's options** — **`useFieldOptions(alias)`** → per select OUTPUT column, its FULL
|
|
37
|
+
`{ key, label, color }` list (incl. options not in any current row) + a `byKey` lookup. Populates
|
|
38
|
+
+ colors pickers and stored values; tracks the table (a newly added/renamed option just renders).
|
|
39
|
+
- **Mutate (the ONLY write path)** — **`useWorkflow(alias)`** → an async fn returning a typed
|
|
40
|
+
`WorkflowResult` `{ status, message?, files?, data? }`. `data` is whatever the workflow
|
|
41
|
+
`return({ data })`'d (typed automatically from the alias contract). After a known mutation,
|
|
42
|
+
call the owning query's `refetch()`.
|
|
43
|
+
- **Upload a file** — **`useFileUpload()`** → `{ upload, uploading, error }`. Mints a presigned URL
|
|
44
|
+
and PUTs the bytes straight to storage; the file is inert until a workflow attaches it to a
|
|
45
|
+
`files` field. Emits `app_file_uploaded`.
|
|
46
|
+
- **List members** — **`useMembers(opts?)`** → `{ members: {id,name,email,image}[], loading, error }`
|
|
47
|
+
— the candidate set for an assign/picker. Gated: the app must declare a `member`-typed input
|
|
48
|
+
(`opts.group` restricts to a declared group). Render with `@lotics/ui` `MemberSelect` / `MemberChip`.
|
|
49
|
+
- **Comments on a record** — **`useComments({ record_id })`** / **`useCommentCounts({ table_id })`**.
|
|
50
|
+
Capability-gated (`"capabilities": { "comments": true }` in the manifest) AND members-only — check
|
|
51
|
+
`available` before showing a composer. Runs under the VIEWING member's authority (author = the
|
|
52
|
+
viewer), unlike queries/workflows (owner authority). Render with `@lotics/ui` `comments_thread`.
|
|
53
|
+
- **Who's viewing** — **`useViewer()`** → the signed-in member (the *viewed* member under "View as").
|
|
54
|
+
Display-only — personalize or attribute; **never** scope rows by it (use an `is_current_member`
|
|
55
|
+
query filter server-side). Returns null for an anonymous public visitor.
|
|
56
|
+
- **Device location** — **`requestGeofencedLocation(zones)`** → a structured outcome
|
|
57
|
+
`{ ok:true, coords } | { ok:false, reason:"denied"|"unavailable"|"outside" }`; **`isWithinZone`**
|
|
58
|
+
is the pure check. Read directly in the iframe (host delegates the permission); the gate is
|
|
59
|
+
client-side advisory — pass `coords` to a workflow to record where an action happened.
|
|
60
|
+
- **Recents** — **`useRecents(key, max)`** → most-recent-first, deduped, capped, `localStorage`-backed
|
|
61
|
+
(web-only, which is why it's here, not in `@lotics/ui`). Feeds a `Combobox`'s `recentOptions`.
|
|
62
|
+
- **Optimistic mutation glue** — **`useOptimistic()`** → reconcile a workflow mutation against the
|
|
63
|
+
query cache for an interactive (calendar/kanban/grid) app. See the data-bound recipe.
|
|
64
|
+
- **Infra** — **`mount(opts?)`** boots the app + analytics (call once at entry; PostHog is automatic,
|
|
65
|
+
no per-app wiring). **`rpc(op, payload)`** is the raw bridge (escape hatch; prefer the hooks).
|
|
66
|
+
**`openExternal(url)`** opens a link in a new tab (scheme-validated; host-mediated in the embed).
|
|
67
|
+
**`downloadFile(name, data, mime?)`** saves browser-built bytes (a `Uint8Array | Blob | string`) —
|
|
68
|
+
call it synchronously from the click that produced them.
|
|
69
|
+
|
|
70
|
+
### Decoding query cells — never hand-roll the serialization contract
|
|
71
|
+
|
|
72
|
+
`useQuery` rows are `Record<string, unknown>`. Coerce each cell with a typed reader (`row.ts` /
|
|
73
|
+
`select.ts` / `members.ts`); never re-implement `firstOpt`/`linkId`/`linkDisplay` — they rot.
|
|
74
|
+
|
|
75
|
+
- **`row.opt/text/num/bool/date/datetime/link`** — scalar coercion. `row.date` = calendar day
|
|
76
|
+
(LOCAL midnight, time stripped); **`row.datetime`** keeps the wall-clock — use it (and project the
|
|
77
|
+
column `type:"datetime"`) when you need the time, or it prints `00:00`.
|
|
78
|
+
- **`readSelect(cell)`** → `ResolvedOption[]` `{ key, label, color? }` (a cell carries key+label; the
|
|
79
|
+
`color` is populated by `useFieldOptions`, not the cell). **`readMembers(cell)`** → `{ id, name,
|
|
80
|
+
email? }[]`. **`readLinks(cell)` / `row.link`** → `{ id, display }`. **`readFiles(cell)`** →
|
|
81
|
+
`AppFile[]` with presigned `url` + `thumbnail_url` (24 h). **`readLocked(row)`** → `boolean` from the
|
|
82
|
+
`__source_locked` addressing column.
|
|
83
|
+
- **`project` only what you render** and `filter` server-side: a bare `from_table` ships every column
|
|
84
|
+
— incl. `files` with storage keys — to the client (over-exposure + presign-500 at scale). A code
|
|
85
|
+
lookup is a parameterized `filter`, not load-all-then-filter-in-JS.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Data discipline (every app, prevents whole bug classes)
|
|
90
|
+
|
|
91
|
+
- **Server data is never copied into `useState`.** `useQuery` / `useWorkflow` results are the source
|
|
92
|
+
of truth — derive everything else with `useMemo`. A second copy drifts and serves stale values.
|
|
93
|
+
- **Prefer derivation + callbacks over `useEffect`.** A derived value is `useMemo`; "state A changed
|
|
94
|
+
→ set state B" is both set in the one triggering callback. `useEffect` is for genuine external
|
|
95
|
+
subscriptions (timers, DOM listeners, storage) — fetching is `useQuery`, not an effect.
|
|
96
|
+
- **No layout shift on load or paging — the UX bar, not a nicety.** Reserve space while data loads:
|
|
97
|
+
(1) first load renders `Skeleton` placeholders that mirror the final layout, not a bare spinner;
|
|
98
|
+
(2) a page change KEEPS the previous rows (`usePaginatedQuery` does this via `keepPreviousData`) —
|
|
99
|
+
gate the skeleton on `loading && rows.length === 0`, never `loading` alone, or every "next page"
|
|
100
|
+
collapses the table; (3) a view↔edit toggle reserves the input's height so pressing Edit never
|
|
101
|
+
reflows.
|
|
102
|
+
- **Diff before update.** An edit form snapshots the record at load and sends only the CHANGED fields
|
|
103
|
+
to its update workflow (declare those inputs optional; an omitted input = not written). A full-form
|
|
104
|
+
snapshot re-writes locked fields (blocked even if unchanged), fires `before_update` autofills
|
|
105
|
+
spuriously, and clobbers concurrent edits. For a locked record (`readLocked`), the save becomes one
|
|
106
|
+
`request_locked_record_change` carrying the diffed `changes` + a reason.
|
|
107
|
+
- **Reuse the kit's utilities** — `@lotics/ui` ships the formatters (`formatMoney`, `formatDate`/
|
|
108
|
+
`parseDate`/`toISODate`); never hand-roll `dd/MM` or `₫`. Grep `@lotics/ui` exports before writing one.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Search-as-you-type
|
|
113
|
+
|
|
114
|
+
A search-first picker (type → ranked results → pick) is one `@lotics/ui` component + three SDK pieces;
|
|
115
|
+
compose these, don't hand-roll search:
|
|
116
|
+
|
|
117
|
+
- **`Combobox`** (`@lotics/ui`) owns the interaction — debounced `onSearchChange`, a popover listbox
|
|
118
|
+
with rich rows (`renderOptionContent` reading `PickerOption.data`), keyboard nav, `recentOptions`,
|
|
119
|
+
`allowCustom`. (For a known small list with no search box, `Picker`.)
|
|
120
|
+
- **A parameterized `search` query** — a `from_table` with `search: "{{params.q}}"` over the maintained
|
|
121
|
+
`search_document`: **diacritics- & case-insensitive** (`"da nang"` matches `"Đà Nẵng"`), trigram-
|
|
122
|
+
indexed (scales), privacy-aware. AND-s with `filter` (search within a scope). Reserve an OR-group of
|
|
123
|
+
per-field `contains` (accent-*sensitive*, unindexed) only when you must bound exactly which fields
|
|
124
|
+
match. **Search-as-you-type uses `search`, never a `contains` OR-group** — a zero-match keystroke on
|
|
125
|
+
`contains` forces a full-table scan that hangs the picker.
|
|
126
|
+
- **`useQuery(alias, params, { enabled })`** — gate on a non-empty term; an empty term matches
|
|
127
|
+
everything (`ILIKE '%%'`) and dumps the table on first paint. `enabled` makes "nothing loads until
|
|
128
|
+
you type" true.
|
|
129
|
+
- **`useRecents`** — persist the picked option; pass its list as `recentOptions`.
|
|
130
|
+
|
|
131
|
+
Fetch detail on select with a SECOND parameterized query (a unique-code `equals`, or a link
|
|
132
|
+
`has_any_of [record_id]`) — never a bare full-table load. Filtering a record by its *own* id isn't a
|
|
133
|
+
compilable AST shape; filter by a unique field value.
|
|
134
|
+
|
|
135
|
+
## Browse + sort + filter (the record picker)
|
|
136
|
+
|
|
137
|
+
When the user doesn't know the term — "show me everything, let me narrow it" — a modal table you can
|
|
138
|
+
browse (numbered pages), search, sort, and filter:
|
|
139
|
+
|
|
140
|
+
- **`TablePicker`** (`@lotics/ui`) is the data-agnostic base (a `Dialog` over `SearchInput` + filter
|
|
141
|
+
pills + `Table` + `Pagination`); it owns nothing about records — the consumer passes `columns` + one
|
|
142
|
+
page of `rows` and owns the search/sort/filter/page state. Filter pills are `ColumnFilter` +
|
|
143
|
+
`columnFilterToConditions`.
|
|
144
|
+
- **A records wrapper lives in the app** (e.g. `record_picker.tsx`) — it needs BOTH `@lotics/ui` and
|
|
145
|
+
the SDK (which is UI-free), so it can't be a package. It runs a named query via `usePaginatedQuery`
|
|
146
|
+
and feeds the page into `TablePicker`. Reuse it for any table by passing a different `alias` + `columns`.
|
|
147
|
+
|
|
148
|
+
The pieces that make it work:
|
|
149
|
+
- **Runtime `sort`/`filter` are server-bounded to the query's OUTPUT columns** (an un-projected
|
|
150
|
+
`field_key` → 400) — the picker can't widen exposure. Build `filter` with `columnFilterToConditions`;
|
|
151
|
+
map sort `{key,order}` → `[{field_key, order}]`.
|
|
152
|
+
- **The total comes from `count: true`** — a single-row COUNT over the filtered set (ignores
|
|
153
|
+
sort/limit/offset), driving "Page 1 of N".
|
|
154
|
+
- **Browse needs an unbounded query** — a baked-in AST `limit` caps the *total*; drop it and let
|
|
155
|
+
`pageSize` drive.
|
|
156
|
+
- **Select filter options:** prefer `useFieldOptions` for the COMPLETE set (not options derived from
|
|
157
|
+
loaded rows, which are incomplete until every page loads).
|
|
158
|
+
|
|
159
|
+
## Rendering table primitives — select colors & members
|
|
160
|
+
|
|
161
|
+
Two of the most common cells render the platform way with zero hand-rolling; hand the value to its
|
|
162
|
+
`@lotics/ui` component — never re-derive an option→color map or a hand-built avatar (both rot):
|
|
163
|
+
|
|
164
|
+
- **Select value** → `OptionBadge` (`@lotics/ui`) with its CONFIGURED color. Stored value:
|
|
165
|
+
`byKey(readSelect(cell)[0]?.key)` from `useFieldOptions`; picker option: a `useFieldOptions` option.
|
|
166
|
+
Multi wraps; a missing/unknown color degrades to neutral.
|
|
167
|
+
- **Member** → `MemberChip` (avatar + name; the universal person render) and `MemberSelect` (the ready
|
|
168
|
+
picker — a `Picker` of `MemberChip`s). Feed from `useMembers`. (Avatars stay in the bounded, cached
|
|
169
|
+
roster — presigned files, never fattened onto every query cell; select color is a cheap token, so it
|
|
170
|
+
rides in `useFieldOptions`.) Full props: `@lotics/ui/AGENTS.md`.
|
|
171
|
+
|
|
172
|
+
## Previewing files
|
|
173
|
+
|
|
174
|
+
Render any uploaded file inline — image, PDF, video, audio, Word, Excel, CSV — with `@lotics/ui`; never
|
|
175
|
+
hand-roll per-type rendering.
|
|
176
|
+
|
|
177
|
+
- **`FileGalleryModal`** (full-screen viewer) delegates to **`FilePreview`** (single-file), dispatching
|
|
178
|
+
by MIME; the frontend gallery uses the same renderer.
|
|
179
|
+
- File cells already carry presigned URLs — decode with **`readFiles`** → `AppFile[]`, map to
|
|
180
|
+
`DisplayFile` (`mime_type`→`mimeType`, `thumbnail_url`→`thumbnailUrl`). No round-trip, no URL
|
|
181
|
+
derivation, no server-side doc→PDF (rendering is client-side).
|
|
182
|
+
- Word/Excel preview lazy-imports `@lotics/docx` + `@lotics/xlsx` (optional peer deps) — an image/PDF
|
|
183
|
+
app installs neither. `@lotics/ui` is i18n/analytics-free: pass `labels` + an `onError`.
|
|
184
|
+
|
|
185
|
+
## Composable optional filters (one query, many scopes)
|
|
186
|
+
|
|
187
|
+
Expose several INDEPENDENT filter axes the caller mixes freely from ONE named query — don't shard into
|
|
188
|
+
a query-per-combination. Mark each scoping param `required: false`; the server **prunes every filter
|
|
189
|
+
condition whose `{{params.x}}` the caller didn't pass** (then collapses empty groups), so an unset axis
|
|
190
|
+
stops constraining instead of erroring (no-op for all-required queries).
|
|
191
|
+
|
|
192
|
+
```jsonc
|
|
193
|
+
"search": {
|
|
194
|
+
"ast": { "kind": "project", "from": { "kind": "from_table", "table_id": "tbl_items", "filter": {
|
|
195
|
+
"node_type": "group", "logic": "and", "children": [
|
|
196
|
+
{ "node_type": "condition", "field_key": "status", "operator": "has_any_of", "value": ["{{params.status}}"] },
|
|
197
|
+
// keyword over two fields — the whole OR-group prunes when `keyword` is absent
|
|
198
|
+
{ "node_type": "group", "logic": "or", "children": [
|
|
199
|
+
{ "node_type": "condition", "field_key": "title", "operator": "contains", "value": "{{params.keyword}}" },
|
|
200
|
+
{ "node_type": "condition", "field_key": "notes", "operator": "contains", "value": "{{params.keyword}}" } ] },
|
|
201
|
+
// date range — each bound is its OWN single-param condition, so an open-ended range prunes one side
|
|
202
|
+
{ "node_type": "condition", "field_key": "created", "operator": "on_or_after",
|
|
203
|
+
"value": { "type": "exact", "date": "{{params.from}}", "time": null } },
|
|
204
|
+
{ "node_type": "condition", "field_key": "created", "operator": "on_or_before",
|
|
205
|
+
"value": { "type": "exact", "date": "{{params.to}}", "time": null } } ] } }, "columns": [ /* … */ ] },
|
|
206
|
+
"params": {
|
|
207
|
+
"status": { "type": "select", "options": [ /* … */ ], "required": false },
|
|
208
|
+
"keyword": { "type": "text", "required": false },
|
|
209
|
+
"from": { "type": "date", "required": false },
|
|
210
|
+
"to": { "type": "date", "required": false } }
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`useQuery("search", { keyword })` filters by keyword only; `{}` returns everything. **Dates:** there's no
|
|
215
|
+
`date_range` param — embed a `date`/`text` param in a hand-built `DateTimePoint`
|
|
216
|
+
(`{type:"exact", date:"{{params.from}}", time:null}`), one condition per bound so each prunes
|
|
217
|
+
independently. Keep a scope that must always apply `required` (a missing required param 400s). Typos
|
|
218
|
+
can't widen — deploy rejects a `{{params.x}}` with no declared param.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Recipes (app actions beyond the hooks)
|
|
223
|
+
|
|
224
|
+
Reverse-engineered once; full code in the `/app` skill's `references/recipes.md`.
|
|
225
|
+
|
|
226
|
+
- **Generate a document → download** — the file returns in `WorkflowResult.files[]` (auto-extracted from
|
|
227
|
+
any step's `file_id`); open via `openExternal(files[0].url)`. Don't hand back a `url`/`file_id` from
|
|
228
|
+
`return()`.
|
|
229
|
+
- **Export displayed data → .xlsx (client-side)** — `buildDataWorkbook({columns, rows})` (`@lotics/xlsx`)
|
|
230
|
+
→ `exportWorkbook` → `downloadFile`. To export the WHOLE result (not one page), page the query
|
|
231
|
+
imperatively with `rpc("query", { alias, params, limit, offset })` to exhaustion (embedded-app only —
|
|
232
|
+
the public transport drops `sort`/`filter`).
|
|
233
|
+
- **Workflow returns data → fill the UI** — `return({ data })` hands back any structured result, read as
|
|
234
|
+
a typed `result.data` (no schema needed; narrow before use — a fall-through completion returns none).
|
|
235
|
+
- **Look up one record by a typed code** — a parameterized `{{params.x}}` filter + `useQuery(alias, {...})`,
|
|
236
|
+
so the client never receives other rows. Autonumber fields filter as text. (Public-app IDOR caveat:
|
|
237
|
+
`docs/apps.md`.)
|
|
238
|
+
- **Interactive (read + mutate) app** — `row.*` to coerce + `useOptimistic` to reconcile; `@lotics/ui`
|
|
239
|
+
provides the drag-enabled calendar/gantt/kanban/grid.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Authority & scoping (the one-paragraph rule — full model in `docs/apps.md`)
|
|
244
|
+
|
|
245
|
+
App data ops run under the **app owner's** principal, not the viewer's — so who-is-acting and per-user
|
|
246
|
+
scoping are the author's job, never inferred. **Reads:** scope "my X" with the `is_current_member`
|
|
247
|
+
operator in the query TEMPLATE (the server binds the signed-in viewer / the view-as subject) — never a
|
|
248
|
+
client-supplied `member_id` (that's an IDOR; per-user data must not ship in a *public* app). **Writes:**
|
|
249
|
+
derive the actor server-side — read `runtime.triggered_by_member_id` in the workflow body; a client
|
|
250
|
+
`member` input is spoofable. **Privileged writes** (a role/group gate) authorize the caller in the
|
|
251
|
+
workflow with `current_member_in_any_group(...)`, paired with a manager-only read. `useViewer()` is
|
|
252
|
+
display-only, not an authorization fact. The full IAM model, public-access bounds, and the security
|
|
253
|
+
rationale: **`docs/apps.md`**.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Keeping this file current
|
|
258
|
+
|
|
259
|
+
This is the published SDK reference — agents read `node_modules/@lotics/app-sdk/AGENTS.md` when building
|
|
260
|
+
apps. **Codify every new hook, reader, and load-bearing pattern here** in the same change that adds it
|
|
261
|
+
(and bump the package version so it ships). Keep it tight: the catalog points at the `.d.ts` for exact
|
|
262
|
+
signatures; the model/IAM/security rationale stays in `docs/apps.md` — link, don't duplicate.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a backend app-agent run stream into the app's agent-run model.
|
|
3
|
+
*
|
|
4
|
+
* The backend streams the AI-SDK "UI message" SSE protocol (the same wire format
|
|
5
|
+
* the chat uses) — `data: <json>\n\n` frames, each a typed chunk, terminated by
|
|
6
|
+
* `data: [DONE]`. The app SDK consumes it WITHOUT depending on the `ai` package
|
|
7
|
+
* (the sandboxed app bundle stays lean): we read the handful of chunk types that
|
|
8
|
+
* matter for a run feed and fold them into `AgentRunState`. Unknown chunk types
|
|
9
|
+
* are ignored, so an AI-SDK version that adds chunk kinds never breaks the SDK.
|
|
10
|
+
*
|
|
11
|
+
* The structured result is the input of the agent's `submit_result` tool call
|
|
12
|
+
* (the backend injects that tool when the agent declares `outputs`); a free-text
|
|
13
|
+
* agent's result is the accumulated text. Pure + reducer-shaped so it is fully
|
|
14
|
+
* unit-testable against recorded frames — no live model needed.
|
|
15
|
+
*/
|
|
16
|
+
export interface AgentRunStep {
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
detail?: string;
|
|
20
|
+
status: "running" | "done" | "error";
|
|
21
|
+
kind?: "step" | "tool";
|
|
22
|
+
}
|
|
23
|
+
export interface AgentRunState {
|
|
24
|
+
status: "streaming" | "completed" | "error";
|
|
25
|
+
/** The agent's streamed reasoning / answer text, accumulated. */
|
|
26
|
+
text: string;
|
|
27
|
+
/** Tool calls the agent made, in order (excludes the internal `submit_result`). */
|
|
28
|
+
steps: AgentRunStep[];
|
|
29
|
+
/** The structured result — `submit_result`'s input, or (free-text) the final text. */
|
|
30
|
+
output?: unknown;
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function initialAgentRunState(): AgentRunState;
|
|
34
|
+
/** A parsed UI-message chunk — only the fields we read, all optional. */
|
|
35
|
+
interface Chunk {
|
|
36
|
+
type?: string;
|
|
37
|
+
delta?: string;
|
|
38
|
+
toolName?: string;
|
|
39
|
+
toolCallId?: string;
|
|
40
|
+
input?: unknown;
|
|
41
|
+
errorText?: string;
|
|
42
|
+
finishReason?: string;
|
|
43
|
+
}
|
|
44
|
+
/** Fold one chunk into the run state. Returns a new state (immutable). */
|
|
45
|
+
export declare function reduceAgentChunk(state: AgentRunState, chunk: Chunk): AgentRunState;
|
|
46
|
+
/**
|
|
47
|
+
* Incremental SSE frame splitter. Feed it raw text as it arrives; it returns the
|
|
48
|
+
* complete chunks parsed so far and the leftover partial frame to carry forward.
|
|
49
|
+
* `[DONE]` is dropped (the `finish` chunk already settles the state).
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseSseChunks(buffer: string): {
|
|
52
|
+
chunks: Chunk[];
|
|
53
|
+
rest: string;
|
|
54
|
+
};
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a backend app-agent run stream into the app's agent-run model.
|
|
3
|
+
*
|
|
4
|
+
* The backend streams the AI-SDK "UI message" SSE protocol (the same wire format
|
|
5
|
+
* the chat uses) — `data: <json>\n\n` frames, each a typed chunk, terminated by
|
|
6
|
+
* `data: [DONE]`. The app SDK consumes it WITHOUT depending on the `ai` package
|
|
7
|
+
* (the sandboxed app bundle stays lean): we read the handful of chunk types that
|
|
8
|
+
* matter for a run feed and fold them into `AgentRunState`. Unknown chunk types
|
|
9
|
+
* are ignored, so an AI-SDK version that adds chunk kinds never breaks the SDK.
|
|
10
|
+
*
|
|
11
|
+
* The structured result is the input of the agent's `submit_result` tool call
|
|
12
|
+
* (the backend injects that tool when the agent declares `outputs`); a free-text
|
|
13
|
+
* agent's result is the accumulated text. Pure + reducer-shaped so it is fully
|
|
14
|
+
* unit-testable against recorded frames — no live model needed.
|
|
15
|
+
*/
|
|
16
|
+
/** The tool the backend injects to carry a typed structured result. */
|
|
17
|
+
const SUBMIT_TOOL = "submit_result";
|
|
18
|
+
export function initialAgentRunState() {
|
|
19
|
+
return { status: "streaming", text: "", steps: [] };
|
|
20
|
+
}
|
|
21
|
+
/** Fold one chunk into the run state. Returns a new state (immutable). */
|
|
22
|
+
export function reduceAgentChunk(state, chunk) {
|
|
23
|
+
switch (chunk.type) {
|
|
24
|
+
case "text-delta":
|
|
25
|
+
case "reasoning-delta": {
|
|
26
|
+
const piece = chunk.delta ?? "";
|
|
27
|
+
return piece ? { ...state, text: state.text + piece } : state;
|
|
28
|
+
}
|
|
29
|
+
case "tool-input-available": {
|
|
30
|
+
const name = chunk.toolName ?? "tool";
|
|
31
|
+
if (name === SUBMIT_TOOL) {
|
|
32
|
+
// The agent emitted its structured result.
|
|
33
|
+
return { ...state, output: chunk.input };
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
...state,
|
|
37
|
+
steps: [
|
|
38
|
+
...state.steps,
|
|
39
|
+
{ id: chunk.toolCallId ?? `${name}-${state.steps.length}`, label: name, kind: "tool", status: "done" },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
case "error":
|
|
44
|
+
return { ...state, status: "error", error: chunk.errorText ?? "The run failed." };
|
|
45
|
+
case "abort":
|
|
46
|
+
return { ...state, status: state.status === "streaming" ? "error" : state.status, error: state.error ?? "The run was stopped." };
|
|
47
|
+
case "finish": {
|
|
48
|
+
const output = state.output ?? (state.text.trim() ? state.text.trim() : undefined);
|
|
49
|
+
return { ...state, status: state.status === "error" ? "error" : "completed", output };
|
|
50
|
+
}
|
|
51
|
+
default:
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Incremental SSE frame splitter. Feed it raw text as it arrives; it returns the
|
|
57
|
+
* complete chunks parsed so far and the leftover partial frame to carry forward.
|
|
58
|
+
* `[DONE]` is dropped (the `finish` chunk already settles the state).
|
|
59
|
+
*/
|
|
60
|
+
export function parseSseChunks(buffer) {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
const frames = buffer.split("\n\n");
|
|
63
|
+
const rest = frames.pop() ?? ""; // last element is the incomplete frame
|
|
64
|
+
for (const frame of frames) {
|
|
65
|
+
for (const line of frame.split("\n")) {
|
|
66
|
+
const trimmed = line.startsWith("data:") ? line.slice(5).trim() : "";
|
|
67
|
+
if (!trimmed || trimmed === "[DONE]")
|
|
68
|
+
continue;
|
|
69
|
+
try {
|
|
70
|
+
chunks.push(JSON.parse(trimmed));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// a non-JSON data line — skip it, never throw on the stream
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { chunks, rest };
|
|
78
|
+
}
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { type AgentRunStep } from "./agent_stream.js";
|
|
2
|
+
import type { AppWorkflows, AppWorkflowResults, AppQueries, AppAgents, AppAgentResults } from "./types.js";
|
|
2
3
|
import type { ResolvedMember } from "./members.js";
|
|
3
4
|
import type { ResolvedOption } from "./select.js";
|
|
5
|
+
export type { AgentRunStep, AgentRunState } from "./agent_stream.js";
|
|
4
6
|
/** Fields shared by every query hook's return value. */
|
|
5
7
|
interface QueryStateBase {
|
|
6
8
|
/**
|
|
@@ -347,4 +349,72 @@ export interface MembersOptions {
|
|
|
347
349
|
* ```
|
|
348
350
|
*/
|
|
349
351
|
export declare function useMembers(opts?: MembersOptions): MembersState;
|
|
350
|
-
|
|
352
|
+
type AgentOutputOf<K extends string> = K extends keyof AppAgentResults ? AppAgentResults[K] : unknown;
|
|
353
|
+
/** Options for one `run(...)` call — the app-owned session key it belongs to. */
|
|
354
|
+
export interface AgentRunOptions {
|
|
355
|
+
/** Groups this run with prior runs in the same working session; the agent
|
|
356
|
+
* re-reads them for context. Mint a new id to "clear context". */
|
|
357
|
+
sessionId: string;
|
|
358
|
+
}
|
|
359
|
+
/** The live state + controls returned by `useAgentRun`. */
|
|
360
|
+
export interface UseAgentRun<TInput, TOutput> {
|
|
361
|
+
/** Start a run — streams progress into this hook's state and resolves to the
|
|
362
|
+
* structured output (undefined for a free-text or failed run). Calling again
|
|
363
|
+
* aborts any run still in flight. */
|
|
364
|
+
run: (input: TInput, opts: AgentRunOptions) => Promise<TOutput | undefined>;
|
|
365
|
+
abort: () => void;
|
|
366
|
+
status: "idle" | "streaming" | "completed" | "error";
|
|
367
|
+
/** The agent's streamed reasoning / answer text, accumulating live. */
|
|
368
|
+
text: string;
|
|
369
|
+
/** The agent's tool calls so far — bind to `@lotics/ui` `AgentRun`/`AgentProgress`. */
|
|
370
|
+
steps: AgentRunStep[];
|
|
371
|
+
/** The validated structured result, once the run completes. */
|
|
372
|
+
output?: TOutput;
|
|
373
|
+
error?: string;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Run a streaming agent declared in `package.json` lotics.agents and invoked by
|
|
377
|
+
* alias. `run(input, { sessionId })` starts it; progress streams into `status` /
|
|
378
|
+
* `text` / `steps` (feed those straight into `@lotics/ui` `AgentRun` or
|
|
379
|
+
* `AgentProgress`) and the structured result lands in `output`. Read the
|
|
380
|
+
* session's history with `useAgentRuns`.
|
|
381
|
+
*
|
|
382
|
+
* ```tsx
|
|
383
|
+
* const recognize = useAgentRun("recognize");
|
|
384
|
+
* await recognize.run({ image_file_id }, { sessionId });
|
|
385
|
+
* // recognize.status / recognize.steps / recognize.output
|
|
386
|
+
* ```
|
|
387
|
+
*/
|
|
388
|
+
export declare function useAgentRun<K extends keyof AppAgents & string>(alias: K): UseAgentRun<AppAgents[K], AgentOutputOf<K>>;
|
|
389
|
+
export declare function useAgentRun(alias: string): UseAgentRun<Record<string, unknown>, unknown>;
|
|
390
|
+
/** One persisted run in a session's history (the `/agent-runs` row shape). */
|
|
391
|
+
export interface AgentRunRecord {
|
|
392
|
+
id: string;
|
|
393
|
+
agent_alias: string;
|
|
394
|
+
session_id: string;
|
|
395
|
+
status: string;
|
|
396
|
+
input: Record<string, unknown> | null;
|
|
397
|
+
output: unknown;
|
|
398
|
+
error_message: string | null;
|
|
399
|
+
started_at: string;
|
|
400
|
+
completed_at: string | null;
|
|
401
|
+
}
|
|
402
|
+
interface AgentRunsState {
|
|
403
|
+
runs: AgentRunRecord[];
|
|
404
|
+
loading: boolean;
|
|
405
|
+
error: string | null;
|
|
406
|
+
refetch: () => void;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The run history of a session, oldest-first — the persisted outputs the app
|
|
410
|
+
* renders as a session log (NOT a chat: a flat list of past runs). Refetch
|
|
411
|
+
* after a `run(...)` completes to pull in the new one.
|
|
412
|
+
*
|
|
413
|
+
* ```tsx
|
|
414
|
+
* const { runs } = useAgentRuns(sessionId);
|
|
415
|
+
* ```
|
|
416
|
+
*/
|
|
417
|
+
export declare function useAgentRuns(sessionId: string, opts?: {
|
|
418
|
+
enabled?: boolean;
|
|
419
|
+
revalidateOnFocus?: boolean;
|
|
420
|
+
}): AgentRunsState;
|
package/dist/src/hooks.js
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
* cache survives unmount/remount. Mutation / member hooks keep their own local
|
|
16
16
|
* `useState` — they have nothing to share.
|
|
17
17
|
*/
|
|
18
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
18
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
19
19
|
import useSWR from "swr";
|
|
20
20
|
import useSWRInfinite from "swr/infinite";
|
|
21
|
-
import { rpc } from "./rpc.js";
|
|
21
|
+
import { rpc, rpcAgentRun } from "./rpc.js";
|
|
22
|
+
import { initialAgentRunState, reduceAgentChunk, parseSseChunks, } from "./agent_stream.js";
|
|
22
23
|
import { getMockRows } from "./mock.js";
|
|
23
24
|
import { captureAppEvent } from "./analytics.js";
|
|
24
25
|
export function useWorkflow(alias) {
|
|
@@ -289,3 +290,105 @@ export function useMembers(opts) {
|
|
|
289
290
|
}, [group]);
|
|
290
291
|
return state;
|
|
291
292
|
}
|
|
293
|
+
export function useAgentRun(alias) {
|
|
294
|
+
const [state, setState] = useState(null);
|
|
295
|
+
const handleRef = useRef(null);
|
|
296
|
+
// Guards setState after unmount and aborts any in-flight run on unmount, so a
|
|
297
|
+
// stream never keeps writing to a dead component (or leaks the transport).
|
|
298
|
+
const mountedRef = useRef(true);
|
|
299
|
+
useEffect(() => {
|
|
300
|
+
mountedRef.current = true;
|
|
301
|
+
return () => {
|
|
302
|
+
mountedRef.current = false;
|
|
303
|
+
handleRef.current?.abort();
|
|
304
|
+
};
|
|
305
|
+
}, []);
|
|
306
|
+
const safeSetState = useCallback((s) => {
|
|
307
|
+
if (mountedRef.current)
|
|
308
|
+
setState(s);
|
|
309
|
+
}, []);
|
|
310
|
+
const run = useCallback((input, opts) => {
|
|
311
|
+
handleRef.current?.abort();
|
|
312
|
+
let acc = initialAgentRunState();
|
|
313
|
+
let buffer = "";
|
|
314
|
+
let aborted = false;
|
|
315
|
+
safeSetState(acc);
|
|
316
|
+
const handle = rpcAgentRun({ alias, session_id: opts.sessionId, input: input ?? {} }, (textChunk) => {
|
|
317
|
+
if (aborted)
|
|
318
|
+
return;
|
|
319
|
+
buffer += textChunk;
|
|
320
|
+
const { chunks, rest } = parseSseChunks(buffer);
|
|
321
|
+
buffer = rest;
|
|
322
|
+
if (chunks.length === 0)
|
|
323
|
+
return;
|
|
324
|
+
for (const c of chunks)
|
|
325
|
+
acc = reduceAgentChunk(acc, c);
|
|
326
|
+
safeSetState({ ...acc });
|
|
327
|
+
});
|
|
328
|
+
// Wrap abort so a stop (user or unmount) marks the run cancelled and clears
|
|
329
|
+
// the partial state back to idle — `done` resolves cleanly, never an error.
|
|
330
|
+
handleRef.current = {
|
|
331
|
+
abort: () => {
|
|
332
|
+
if (aborted)
|
|
333
|
+
return;
|
|
334
|
+
aborted = true;
|
|
335
|
+
handle.abort();
|
|
336
|
+
safeSetState(null);
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
return handle.done
|
|
340
|
+
.then(() => {
|
|
341
|
+
if (aborted)
|
|
342
|
+
return undefined;
|
|
343
|
+
// `finish` normally settles status; guard a stream that ended without one.
|
|
344
|
+
if (acc.status === "streaming") {
|
|
345
|
+
acc = { ...acc, status: "completed", output: acc.output ?? (acc.text.trim() || undefined) };
|
|
346
|
+
safeSetState(acc);
|
|
347
|
+
}
|
|
348
|
+
captureAppEvent("app_agent_run", { alias, ok: acc.status !== "error" });
|
|
349
|
+
return acc.output;
|
|
350
|
+
})
|
|
351
|
+
.catch((err) => {
|
|
352
|
+
if (aborted)
|
|
353
|
+
return undefined;
|
|
354
|
+
acc = { ...acc, status: "error", error: err.message };
|
|
355
|
+
safeSetState(acc);
|
|
356
|
+
captureAppEvent("app_agent_run", { alias, ok: false });
|
|
357
|
+
throw err;
|
|
358
|
+
});
|
|
359
|
+
}, [alias, safeSetState]);
|
|
360
|
+
const abort = useCallback(() => handleRef.current?.abort(), []);
|
|
361
|
+
return {
|
|
362
|
+
run,
|
|
363
|
+
abort,
|
|
364
|
+
status: state?.status ?? "idle",
|
|
365
|
+
text: state?.text ?? "",
|
|
366
|
+
steps: state?.steps ?? [],
|
|
367
|
+
output: state?.output,
|
|
368
|
+
error: state?.error,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The run history of a session, oldest-first — the persisted outputs the app
|
|
373
|
+
* renders as a session log (NOT a chat: a flat list of past runs). Refetch
|
|
374
|
+
* after a `run(...)` completes to pull in the new one.
|
|
375
|
+
*
|
|
376
|
+
* ```tsx
|
|
377
|
+
* const { runs } = useAgentRuns(sessionId);
|
|
378
|
+
* ```
|
|
379
|
+
*/
|
|
380
|
+
export function useAgentRuns(sessionId, opts) {
|
|
381
|
+
const enabled = opts?.enabled ?? true;
|
|
382
|
+
const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
|
|
383
|
+
const key = enabled && sessionId ? ["app-agent-runs", sessionId] : null;
|
|
384
|
+
const swr = useSWR(key, () => rpc("agentRuns", { session_id: sessionId }), swrConfig(revalidateOnFocus));
|
|
385
|
+
const refetch = useCallback(() => {
|
|
386
|
+
void swr.mutate();
|
|
387
|
+
}, [swr]);
|
|
388
|
+
return {
|
|
389
|
+
runs: swr.data?.runs ?? [],
|
|
390
|
+
loading: swr.isLoading,
|
|
391
|
+
error: swr.error?.message ?? null,
|
|
392
|
+
refetch,
|
|
393
|
+
};
|
|
394
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
export { mount } from "./mount.js";
|
|
18
18
|
export type { MountOptions } from "./mount.js";
|
|
19
|
-
export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useMembers, } from "./hooks.js";
|
|
20
|
-
export type { UploadedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, FieldOptions, FieldOptionsState, FieldOptionsOptions, } from "./hooks.js";
|
|
19
|
+
export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useMembers, useAgentRun, useAgentRuns, } from "./hooks.js";
|
|
20
|
+
export type { UploadedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunRecord, AgentRunState, AgentRunStep, FieldOptions, FieldOptionsState, FieldOptionsOptions, } from "./hooks.js";
|
|
21
21
|
export { useComments, useCommentCounts } from "./comments.js";
|
|
22
22
|
export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
|
|
23
23
|
export { useViewer } from "./viewer.js";
|
|
@@ -32,7 +32,7 @@ export type { ResolvedMember } from "./members.js";
|
|
|
32
32
|
export { readSelect } from "./select.js";
|
|
33
33
|
export type { ResolvedOption } from "./select.js";
|
|
34
34
|
export type { AppFixture } from "./mock.js";
|
|
35
|
-
export type { AppWorkflows, AppQueries } from "./types.js";
|
|
35
|
+
export type { AppWorkflows, AppQueries, AppAgents, AppAgentResults } from "./types.js";
|
|
36
36
|
export { row, readLinks, readFiles, readLocked } from "./row.js";
|
|
37
37
|
export type { ResolvedLink, AppFile } from "./row.js";
|
|
38
38
|
export { useOptimistic } from "./use_optimistic.js";
|
package/dist/src/index.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* not raw HTML/CSS. See `docs/apps.md` → "Styling & components".
|
|
16
16
|
*/
|
|
17
17
|
export { mount } from "./mount.js";
|
|
18
|
-
export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useMembers, } from "./hooks.js";
|
|
18
|
+
export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useMembers, useAgentRun, useAgentRuns, } from "./hooks.js";
|
|
19
19
|
export { useComments, useCommentCounts } from "./comments.js";
|
|
20
20
|
export { useViewer } from "./viewer.js";
|
|
21
21
|
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -18,7 +18,19 @@
|
|
|
18
18
|
* app → host: { id, op, payload }
|
|
19
19
|
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
20
20
|
*/
|
|
21
|
-
export type RpcOp = "query" | "field_options" | "workflow" | "upload" | "members" | "context" | "openExternal" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
|
|
21
|
+
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "upload" | "members" | "context" | "openExternal" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
|
|
22
|
+
/** Payload for starting a streaming agent run. */
|
|
23
|
+
export interface AgentRunPayload {
|
|
24
|
+
alias: string;
|
|
25
|
+
session_id: string;
|
|
26
|
+
input: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
/** Handle to a running stream — `done` settles at the end (or rejects on
|
|
29
|
+
* error); `abort` stops it. The caller feeds each text chunk to the parser. */
|
|
30
|
+
export interface AgentRunHandle {
|
|
31
|
+
done: Promise<void>;
|
|
32
|
+
abort: () => void;
|
|
33
|
+
}
|
|
22
34
|
/**
|
|
23
35
|
* The app's identity, resolved once at startup to tag PostHog events.
|
|
24
36
|
* Assembled by whichever transport is active:
|
|
@@ -46,3 +58,10 @@ export interface AppContext {
|
|
|
46
58
|
comments_enabled: boolean;
|
|
47
59
|
}
|
|
48
60
|
export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
|
|
61
|
+
/**
|
|
62
|
+
* Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
|
|
63
|
+
* (the caller parses it via `agent_stream`); `done` settles when the stream
|
|
64
|
+
* ends. Bridged: the host opens the SSE with its session and forwards chunks;
|
|
65
|
+
* standalone: the SDK reads the public endpoint's body directly.
|
|
66
|
+
*/
|
|
67
|
+
export declare function rpcAgentRun(payload: AgentRunPayload, onText: (chunk: string) => void): AgentRunHandle;
|
package/dist/src/rpc.js
CHANGED
|
@@ -24,6 +24,7 @@ export function rpc(op, payload) {
|
|
|
24
24
|
: rpcStandalone(op, payload);
|
|
25
25
|
}
|
|
26
26
|
const pending = new Map();
|
|
27
|
+
const streaming = new Map();
|
|
27
28
|
let nextRpcId = 0;
|
|
28
29
|
let listenerInstalled = false;
|
|
29
30
|
function ensureListener() {
|
|
@@ -37,15 +38,31 @@ function ensureListener() {
|
|
|
37
38
|
const msg = event.data;
|
|
38
39
|
if (!msg || typeof msg.id !== "number")
|
|
39
40
|
return;
|
|
41
|
+
// Single-response ops.
|
|
40
42
|
const handler = pending.get(msg.id);
|
|
41
|
-
if (
|
|
43
|
+
if (handler) {
|
|
44
|
+
pending.delete(msg.id);
|
|
45
|
+
if (msg.type === "result")
|
|
46
|
+
handler.resolve(msg.data);
|
|
47
|
+
else
|
|
48
|
+
handler.reject(new Error(msg.message ?? "RPC failed"));
|
|
42
49
|
return;
|
|
43
|
-
pending.delete(msg.id);
|
|
44
|
-
if (msg.type === "result") {
|
|
45
|
-
handler.resolve(msg.data);
|
|
46
50
|
}
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
// Streaming op (agentRun): chunk* → end | error.
|
|
52
|
+
const stream = streaming.get(msg.id);
|
|
53
|
+
if (!stream)
|
|
54
|
+
return;
|
|
55
|
+
if (msg.type === "stream-chunk") {
|
|
56
|
+
if (typeof msg.chunk === "string")
|
|
57
|
+
stream.onText(msg.chunk);
|
|
58
|
+
}
|
|
59
|
+
else if (msg.type === "stream-end") {
|
|
60
|
+
streaming.delete(msg.id);
|
|
61
|
+
stream.resolve();
|
|
62
|
+
}
|
|
63
|
+
else if (msg.type === "error") {
|
|
64
|
+
streaming.delete(msg.id);
|
|
65
|
+
stream.reject(new Error(msg.message ?? "Run failed"));
|
|
49
66
|
}
|
|
50
67
|
});
|
|
51
68
|
}
|
|
@@ -57,6 +74,77 @@ function rpcBridged(op, payload, host) {
|
|
|
57
74
|
window.parent.postMessage({ id, op, payload }, host);
|
|
58
75
|
});
|
|
59
76
|
}
|
|
77
|
+
// ── Streaming transport (agent runs) ─────────────────────────────────────────
|
|
78
|
+
/**
|
|
79
|
+
* Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
|
|
80
|
+
* (the caller parses it via `agent_stream`); `done` settles when the stream
|
|
81
|
+
* ends. Bridged: the host opens the SSE with its session and forwards chunks;
|
|
82
|
+
* standalone: the SDK reads the public endpoint's body directly.
|
|
83
|
+
*/
|
|
84
|
+
export function rpcAgentRun(payload, onText) {
|
|
85
|
+
const hostOrigin = getHostOrigin();
|
|
86
|
+
return hostOrigin ? agentRunBridged(payload, onText, hostOrigin) : agentRunStandalone(payload, onText);
|
|
87
|
+
}
|
|
88
|
+
function agentRunBridged(payload, onText, host) {
|
|
89
|
+
ensureListener();
|
|
90
|
+
const id = nextRpcId++;
|
|
91
|
+
let settleDone = () => { };
|
|
92
|
+
const done = new Promise((resolve, reject) => {
|
|
93
|
+
settleDone = resolve;
|
|
94
|
+
streaming.set(id, { onText, resolve, reject });
|
|
95
|
+
window.parent.postMessage({ id, op: "agentRun", payload }, host);
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
done,
|
|
99
|
+
abort: () => {
|
|
100
|
+
if (!streaming.has(id))
|
|
101
|
+
return;
|
|
102
|
+
streaming.delete(id);
|
|
103
|
+
window.parent.postMessage({ id, type: "abort" }, host);
|
|
104
|
+
// The caller stopped the run — resolve `done` cleanly so it never hangs
|
|
105
|
+
// (an abort is not a failure). The host tears down the SSE.
|
|
106
|
+
settleDone();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function agentRunStandalone(payload, onText) {
|
|
111
|
+
const controller = new AbortController();
|
|
112
|
+
const done = (async () => {
|
|
113
|
+
const { app_id } = await boot();
|
|
114
|
+
const headers = { "content-type": "application/json" };
|
|
115
|
+
if (sessionToken)
|
|
116
|
+
headers["authorization"] = `Bearer ${sessionToken}`;
|
|
117
|
+
const res = await fetch(`${API_BASE}/v1/apps/${app_id}/agents/${encodeURIComponent(payload.alias)}/runs`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers,
|
|
120
|
+
body: JSON.stringify({ session_id: payload.session_id, input: payload.input }),
|
|
121
|
+
signal: controller.signal,
|
|
122
|
+
});
|
|
123
|
+
if (!res.ok || !res.body) {
|
|
124
|
+
const text = await res.text().catch(() => "");
|
|
125
|
+
throw new Error(text || `HTTP ${res.status}`);
|
|
126
|
+
}
|
|
127
|
+
const reader = res.body.getReader();
|
|
128
|
+
const decoder = new TextDecoder();
|
|
129
|
+
try {
|
|
130
|
+
for (;;) {
|
|
131
|
+
const { value, done: streamDone } = await reader.read();
|
|
132
|
+
if (streamDone)
|
|
133
|
+
break;
|
|
134
|
+
onText(decoder.decode(value, { stream: true }));
|
|
135
|
+
}
|
|
136
|
+
const tail = decoder.decode(); // flush any bytes held across the final read
|
|
137
|
+
if (tail)
|
|
138
|
+
onText(tail);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (controller.signal.aborted)
|
|
142
|
+
return; // the caller stopped the run — clean, not an error
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
})();
|
|
146
|
+
return { done, abort: () => controller.abort() };
|
|
147
|
+
}
|
|
60
148
|
// ── Standalone transport ────────────────────────────────────────────────────
|
|
61
149
|
// Standalone apps run only at `<slug>.lotics.app` (production); dev apps are
|
|
62
150
|
// always bridged by the `lotics app dev` wrapper.
|
|
@@ -217,6 +305,8 @@ function rpcStandalone(op, payload) {
|
|
|
217
305
|
return standaloneFieldOptions(payload);
|
|
218
306
|
case "workflow":
|
|
219
307
|
return standaloneWorkflow(payload);
|
|
308
|
+
case "agentRuns":
|
|
309
|
+
return standaloneAgentRuns(payload);
|
|
220
310
|
case "upload":
|
|
221
311
|
return standaloneUpload(payload.file);
|
|
222
312
|
case "members":
|
|
@@ -300,6 +390,18 @@ async function standaloneWorkflow(p) {
|
|
|
300
390
|
const { app_id } = await boot();
|
|
301
391
|
return apiCall("POST", `/v1/apps/${app_id}/workflows/${encodeURIComponent(p.alias)}/execute`, { inputs: p.inputs }, { appId: app_id });
|
|
302
392
|
}
|
|
393
|
+
async function standaloneAgentRuns(p) {
|
|
394
|
+
const { app_id } = await boot();
|
|
395
|
+
const qs = new URLSearchParams({ session_id: p.session_id });
|
|
396
|
+
if (p.limit != null)
|
|
397
|
+
qs.set("limit", String(p.limit));
|
|
398
|
+
if (p.offset != null)
|
|
399
|
+
qs.set("offset", String(p.offset));
|
|
400
|
+
const r = (await apiCall("GET", `/v1/apps/${app_id}/agent-runs?${qs.toString()}`, undefined, {
|
|
401
|
+
appId: app_id,
|
|
402
|
+
}));
|
|
403
|
+
return { runs: r.runs ?? [] };
|
|
404
|
+
}
|
|
303
405
|
async function standaloneUpload(file) {
|
|
304
406
|
if (!(file instanceof File)) {
|
|
305
407
|
throw new Error("upload payload must include a File");
|
package/dist/src/types.d.ts
CHANGED
|
@@ -68,3 +68,28 @@ export interface AppWorkflowResults {
|
|
|
68
68
|
*/
|
|
69
69
|
export interface AppQueries {
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* App-specific AGENT augmentation point — same pattern as `AppWorkflows`, for
|
|
73
|
+
* the streaming agents declared in `package.json` lotics.agents and invoked via
|
|
74
|
+
* `useAgentRun(alias)`. Per-app codegen maps each alias to its declared input
|
|
75
|
+
* shape, so an undeclared alias is a compile-time error and the run input is
|
|
76
|
+
* typed:
|
|
77
|
+
*
|
|
78
|
+
* ```ts
|
|
79
|
+
* declare module "@lotics/app-sdk" {
|
|
80
|
+
* interface AppAgents {
|
|
81
|
+
* "recognize": { image_file_id: string };
|
|
82
|
+
* "edit": { current: Record<string, unknown>; prompt: string };
|
|
83
|
+
* }
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export interface AppAgents {
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* App-specific agent-RESULT augmentation point — maps each agent alias to the
|
|
91
|
+
* type of its declared `outputs` (the structured result the run emits). Absent
|
|
92
|
+
* ⇒ `run.output` is `unknown`. Same pattern as `AppWorkflowResults`.
|
|
93
|
+
*/
|
|
94
|
+
export interface AppAgentResults {
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"types": "./dist/src/index.d.ts",
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
|
-
"
|
|
12
|
+
"AGENTS.md"
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsgo",
|