@lotics/app-sdk 0.59.1 → 0.60.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/dist/src/hooks.js +10 -0
- package/dist/src/rpc.d.ts +10 -0
- package/dist/src/rpc.js +29 -6
- package/docs/ai.md +10 -0
- package/docs/data_fetching.md +7 -2
- package/package.json +2 -2
package/dist/src/hooks.js
CHANGED
|
@@ -30,10 +30,20 @@ export function useWorkflow(alias) {
|
|
|
30
30
|
// Shared SWR config: surface a failed query immediately, keep the last good
|
|
31
31
|
// rows (no retry loop that masks the error), and honor the focus/reconnect
|
|
32
32
|
// opt-out.
|
|
33
|
+
//
|
|
34
|
+
// `revalidateIfStale: false` — SWR defaults this to `true`, and its initial
|
|
35
|
+
// -revalidation decision is `isUndefined(data) || revalidateIfStale`, so every
|
|
36
|
+
// re-mount of a screen that already had rows re-queried the backend. An app
|
|
37
|
+
// that navigates between screens (or re-opens a drawer) paid a full query set
|
|
38
|
+
// each time for data it was already showing. A cold mount still fetches
|
|
39
|
+
// (`isUndefined(data)` short-circuits), focus/reconnect still refresh, and the
|
|
40
|
+
// host's `refetchQueries` poke still forces a refresh after a chat turn mutates
|
|
41
|
+
// records — so freshness keeps every path it had except "re-mounted".
|
|
33
42
|
function swrConfig(revalidateOnFocus) {
|
|
34
43
|
return {
|
|
35
44
|
revalidateOnFocus,
|
|
36
45
|
revalidateOnReconnect: revalidateOnFocus,
|
|
46
|
+
revalidateIfStale: false,
|
|
37
47
|
shouldRetryOnError: false,
|
|
38
48
|
};
|
|
39
49
|
}
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -178,6 +178,16 @@ export declare const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
|
|
|
178
178
|
* `parsed` is the JSON.parse of the body, or `null` if it wasn't JSON.
|
|
179
179
|
*/
|
|
180
180
|
export declare function transportErrorMessage(status: number, parsed: unknown): string;
|
|
181
|
+
/**
|
|
182
|
+
* The error a stream that never started should throw.
|
|
183
|
+
*
|
|
184
|
+
* A streaming endpoint fails BEFORE the first byte like any other request — a 402 when the
|
|
185
|
+
* workspace is out of credits, a 403, a 404. Those bodies are structured errors, and throwing
|
|
186
|
+
* the body TEXT put the whole JSON on screen: `{"message":"Credit quota exceeded…","error":
|
|
187
|
+
* "quota_exceeded","plan_id":"free",…}`. Same rule as every other call: the `message` is the
|
|
188
|
+
* message, and a non-JSON or 5xx body never becomes one.
|
|
189
|
+
*/
|
|
190
|
+
export declare function streamStartError(res: Response): Promise<Error>;
|
|
181
191
|
/**
|
|
182
192
|
* A package installation's alias→concrete-id maps — what the generated
|
|
183
193
|
* `.lotics/app_fields.ts` of a package project resolves `F`/`OPT`/`ROLE`
|
package/dist/src/rpc.js
CHANGED
|
@@ -257,8 +257,7 @@ export function rpcAgentRunContinue(payload, onText) {
|
|
|
257
257
|
signal: controller.signal,
|
|
258
258
|
});
|
|
259
259
|
if (!res.ok || !res.body) {
|
|
260
|
-
|
|
261
|
-
throw new Error(text || `HTTP ${res.status}`);
|
|
260
|
+
throw await streamStartError(res);
|
|
262
261
|
}
|
|
263
262
|
const reader = res.body.getReader();
|
|
264
263
|
const decoder = new TextDecoder();
|
|
@@ -295,8 +294,7 @@ function agentRunStandalone(payload, onText, onRunId) {
|
|
|
295
294
|
signal: controller.signal,
|
|
296
295
|
});
|
|
297
296
|
if (!res.ok || !res.body) {
|
|
298
|
-
|
|
299
|
-
throw new Error(text || `HTTP ${res.status}`);
|
|
297
|
+
throw await streamStartError(res);
|
|
300
298
|
}
|
|
301
299
|
const runId = res.headers.get("x-app-agent-run-id");
|
|
302
300
|
if (runId)
|
|
@@ -410,8 +408,11 @@ async function boot() {
|
|
|
410
408
|
* A user-facing message for a transport/gateway failure — derived from the HTTP
|
|
411
409
|
* status, never from the response body. A 524 (Cloudflare edge timeout on a long
|
|
412
410
|
* run), any 5xx, or a non-JSON body (an HTML error page) must NOT surface its raw
|
|
413
|
-
* body as the error message.
|
|
414
|
-
*
|
|
411
|
+
* body as the error message.
|
|
412
|
+
*
|
|
413
|
+
* A by-value MIRROR of `@lotics/shared/transport_error`, which is canonical. This
|
|
414
|
+
* package ships to npm with zero internal dependencies, so it cannot import it;
|
|
415
|
+
* every other transport does. Change one, change both.
|
|
415
416
|
*/
|
|
416
417
|
function gatewayErrorMessage(status) {
|
|
417
418
|
if (status === 524) {
|
|
@@ -437,6 +438,28 @@ export function transportErrorMessage(status, parsed) {
|
|
|
437
438
|
? gatewayErrorMessage(status)
|
|
438
439
|
: jsonMessage;
|
|
439
440
|
}
|
|
441
|
+
/**
|
|
442
|
+
* The error a stream that never started should throw.
|
|
443
|
+
*
|
|
444
|
+
* A streaming endpoint fails BEFORE the first byte like any other request — a 402 when the
|
|
445
|
+
* workspace is out of credits, a 403, a 404. Those bodies are structured errors, and throwing
|
|
446
|
+
* the body TEXT put the whole JSON on screen: `{"message":"Credit quota exceeded…","error":
|
|
447
|
+
* "quota_exceeded","plan_id":"free",…}`. Same rule as every other call: the `message` is the
|
|
448
|
+
* message, and a non-JSON or 5xx body never becomes one.
|
|
449
|
+
*/
|
|
450
|
+
export async function streamStartError(res) {
|
|
451
|
+
const text = await res.text().catch(() => "");
|
|
452
|
+
let parsed = null;
|
|
453
|
+
if (text) {
|
|
454
|
+
try {
|
|
455
|
+
parsed = JSON.parse(text);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// not JSON — a gateway HTML page; transportErrorMessage will not surface it
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return new Error(transportErrorMessage(res.status, parsed));
|
|
462
|
+
}
|
|
440
463
|
// Standalone requests carried no timeout: a hung or slow backend pinned the
|
|
441
464
|
// fetch — and one of the browser's few per-origin connections — indefinitely,
|
|
442
465
|
// surfacing as an app that "loads forever". Bound every standalone call the way
|
package/docs/ai.md
CHANGED
|
@@ -90,6 +90,16 @@ The terminal `submit_result` call is captured into `output`, **not** rendered as
|
|
|
90
90
|
|
|
91
91
|
`AgentRun` renders thinking collapsed, groups consecutive tool calls, and expands each tool's input/output in place on press — all for free. Running it in a bounded container (a dialog, a panel)? Wrap it in `@lotics/ui`'s `FollowScroll` so the container follows the stream instead of letting new content grow below the fold. **Errors surface two ways:** a per-tool failure is an `output-error` part (amber dot, reason in the expanded Error panel — the feed flows on, exactly like a run that retried and recovered); a **breaking** error that killed the run lives in `run.error` (not in `parts`) — pass it as `error` and it renders as a terminal danger row. Never rebuild this feed by hand.
|
|
92
92
|
|
|
93
|
+
`run.error` is always a **sentence to show a person**, never a payload — on both the ways a run can fail.
|
|
94
|
+
|
|
95
|
+
*Before it starts*: the request is refused (most often a 402 when the workspace is out of AI credits — "Credit quota exceeded. Upgrade your plan for continued AI access." — or a 403/404). You get that response's `message` alone; the rest of the body (`error`, `plan_id`, `credits_used`, …) never reaches the string, and a non-JSON body or any 5xx is replaced by a status-derived message.
|
|
96
|
+
|
|
97
|
+
*After it starts*: the run dies mid-flight, and the message is written **in the triggering member's language** (the workspace default when an anonymous visitor triggered it). An error the platform wrote surfaces as-is, so running out of credits **during** a run still says to upgrade rather than to retry. A provider or tool failure does NOT — its wording is a debugging string that names our request shape, so it becomes one of two sentences: the request itself was rejected (an attachment that is corrupt or too large — retrying unchanged cannot help), or it broke transiently and is worth another run. The raw error is kept in error tracking with the run id.
|
|
98
|
+
|
|
99
|
+
Two caveats on that language guarantee. A message written at its throw site rather than fixed on its error class stays English — those strings are also what the chat agent reads to correct its own tool calls, so they are deliberately one language. And the *before it starts* case above is the API response's own `message`, which is not localized either. So an app that must be wholly in one language should render its own copy on failure rather than the platform's.
|
|
100
|
+
|
|
101
|
+
So `error={run.error}` is safe to render verbatim. What it is NOT is machine-readable: `status` tells you a run failed, not why, and the difference between "worth retrying" and "retrying cannot help" lives in the sentence rather than in a code. **Do not parse it** — the wording is copy and will change. If your app needs to act on the distinction (a retry button that disables itself when a retry is futile, an upgrade prompt on a quota hit), that discriminator can be exposed; it is classified server-side already and simply is not on the wire yet.
|
|
102
|
+
|
|
93
103
|
### The agent asks back — `pendingChoice` / `answerChoice`
|
|
94
104
|
|
|
95
105
|
Every app agent carries the platform's MANDATORY interactive tool `ask_user_choice` —
|
package/docs/data_fetching.md
CHANGED
|
@@ -68,8 +68,13 @@ server validates system conditions by `type` and never reads `field_key` on them
|
|
|
68
68
|
the paged hooks). Object *contents* are hashed, not references — passing a fresh inline
|
|
69
69
|
`{ status: "open" }` each render is the same key; you never need to memoize params.
|
|
70
70
|
- The cache **survives unmount/remount**: returning to a screen renders the cached rows instantly
|
|
71
|
-
and
|
|
72
|
-
|
|
71
|
+
and **sends no request**. Identical concurrent reads dedupe to one request.
|
|
72
|
+
- **A re-mount is not a refresh event.** Freshness comes from window focus / tab return / network
|
|
73
|
+
reconnect (`revalidateOnFocus`, default on), the host's post-chat-turn refetch poke, and explicit
|
|
74
|
+
`refetch()`. Re-mounting a screen you already loaded is none of those, so it re-uses the cache.
|
|
75
|
+
A **cold** key (never loaded, or a new `(alias, params, pageSize, sort, filter)` tuple) always
|
|
76
|
+
fetches — this only affects keys that already hold rows.
|
|
77
|
+
After a write the user is watching for, call `refetch()`; never rely on navigation to refresh.
|
|
73
78
|
- **`loading`** is `true` only on the *initial* load of a key — a request is in flight and there
|
|
74
79
|
are no rows yet. It stays `false` during background revalidation of a key that already has rows,
|
|
75
80
|
so consumers never blank loaded data to a spinner on refetch. A key *change* (new params, sort,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Runtime SDK for Lotics custom-code apps
|
|
3
|
+
"version": "0.60.0",
|
|
4
|
+
"description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|