@lotics/app-sdk 0.36.1 → 0.38.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 +20 -7
- package/dist/src/hooks.d.ts +5 -0
- package/dist/src/hooks.js +57 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/rpc.d.ts +2 -2
- package/dist/src/rpc.js +38 -7
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -53,7 +53,11 @@ Pick by intent. (→ open the `.d.ts` for the exact signature.)
|
|
|
53
53
|
data→UI adapter; the SDK never imports `@lotics/ui`) for
|
|
54
54
|
`<FileThumbnail file={{ id, filename, mimeType, url }} uploading={f.status === "uploading"} />`;
|
|
55
55
|
`sendDisabled` gates on `uploading` and the send payload is `fileIds`. Don't hand-roll
|
|
56
|
-
`createObjectURL`/upload/revoke per app.
|
|
56
|
+
`createObjectURL`/upload/revoke per app. For a full add-files SCREEN (not the composer pill), map
|
|
57
|
+
each `AttachedFile` to a `FileUpload` (ready → `{ status: "complete", id, file }`, else
|
|
58
|
+
`{ status, id, filename, mimeType: mime_type, previewUrl: preview_url }`) and feed `@lotics/ui`
|
|
59
|
+
`FileGrid`'s `uploads` — it renders the uploading/error/retry tiles itself (see the kit AGENTS.md
|
|
60
|
+
Attachments recipe).
|
|
57
61
|
- **List members** — **`useMembers(opts?)`** → `{ members: {id,name,email,image}[], loading, error }`
|
|
58
62
|
— the candidate set for an assign/picker. Gated: the app must declare a `member`-typed input
|
|
59
63
|
(`opts.group` restricts to a declared group). Render with `@lotics/ui` `MemberSelect` / `MemberChip`.
|
|
@@ -183,15 +187,24 @@ Two of the most common cells render the platform way with zero hand-rolling; han
|
|
|
183
187
|
## Previewing files
|
|
184
188
|
|
|
185
189
|
Render any uploaded file inline — image, PDF, video, audio, Word, Excel, CSV — with `@lotics/ui`; never
|
|
186
|
-
hand-roll per-type rendering
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
+
hand-roll per-type rendering, and don't fall back to `openExternal` for a preview (that's "download /
|
|
191
|
+
open elsewhere", not viewing).
|
|
192
|
+
|
|
193
|
+
- **`FileGalleryModal`** (full-screen viewer: toolbar = filename · counter · ⋯actions · close-✕, plus
|
|
194
|
+
prev/next + ESC) delegates to **`FilePreview`** (single-file), dispatching by MIME; the frontend
|
|
195
|
+
gallery uses the same renderer. Wire `onFilePress` → a `number|null` `activeIndex`.
|
|
196
|
+
- **PDF renders INLINE** — bytes fetched and painted to a canvas (with a selectable text layer) via
|
|
197
|
+
`pdfjs-dist`, NOT a native `<iframe>` viewer: a nested PDF browsing context is blocked inside the
|
|
198
|
+
sandboxed, cross-origin app iframe; a canvas isn't, so preview works. `pdfjs-dist` ships in the app
|
|
199
|
+
starter (lazy-loaded — non-PDF apps pay no bundle cost).
|
|
190
200
|
- File cells already carry presigned URLs — decode with **`readFiles`** → `AppFile[]`, map to
|
|
191
201
|
`DisplayFile` (`mime_type`→`mimeType`, `thumbnail_url`→`thumbnailUrl`). No round-trip, no URL
|
|
192
202
|
derivation, no server-side doc→PDF (rendering is client-side).
|
|
193
|
-
-
|
|
194
|
-
app
|
|
203
|
+
- PDF (`pdfjs-dist`), Word (`@lotics/docx`), and Excel/CSV (`@lotics/xlsx`) all ship in the app starter,
|
|
204
|
+
lazy-imported — an app that never previews a type pays no bundle cost. Excel renders to a canvas with
|
|
205
|
+
live formula recalc; Word renders OOXML to the DOM. `@lotics/ui` is i18n/analytics-free: pass `labels`
|
|
206
|
+
+ an `onError`. The toolbar's "open in new tab" can't pop a window in the sandbox — pass `onOpenExternal`
|
|
207
|
+
wired to the SDK's `openExternal` (omit it and the action hides).
|
|
195
208
|
|
|
196
209
|
## Composable optional filters (one query, many scopes)
|
|
197
210
|
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -414,7 +414,12 @@ export interface UseAgentRun<TInput, TOutput> {
|
|
|
414
414
|
* structured output (undefined for a free-text or failed run). Calling again
|
|
415
415
|
* aborts any run still in flight. */
|
|
416
416
|
run: (input: TInput, opts: AgentRunOptions) => Promise<TOutput | undefined>;
|
|
417
|
+
/** Stop listening locally (no server effect) — the run keeps executing
|
|
418
|
+
* server-side and its result lands in the session history. Used on unmount. */
|
|
417
419
|
abort: () => void;
|
|
420
|
+
/** Explicitly stop the run server-side (saves tokens) AND locally. Wire a
|
|
421
|
+
* user-facing "Stop" button to this, not `abort`. */
|
|
422
|
+
cancel: () => void;
|
|
418
423
|
status: "idle" | "streaming" | "completed" | "error";
|
|
419
424
|
/** The agent's streamed reasoning / answer text, accumulating live. */
|
|
420
425
|
text: string;
|
package/dist/src/hooks.js
CHANGED
|
@@ -351,6 +351,10 @@ export function useMembers(opts) {
|
|
|
351
351
|
export function useAgentRun(alias) {
|
|
352
352
|
const [state, setState] = useState(null);
|
|
353
353
|
const handleRef = useRef(null);
|
|
354
|
+
// The run id of the in-flight run (reported by the transport off the response
|
|
355
|
+
// header). Lets the hook poll the run to completion if the stream connection
|
|
356
|
+
// drops, and cancel it server-side on an explicit stop.
|
|
357
|
+
const runIdRef = useRef(null);
|
|
354
358
|
// Guards setState after unmount and aborts any in-flight run on unmount, so a
|
|
355
359
|
// stream never keeps writing to a dead component (or leaks the transport).
|
|
356
360
|
const mountedRef = useRef(true);
|
|
@@ -367,6 +371,7 @@ export function useAgentRun(alias) {
|
|
|
367
371
|
}, []);
|
|
368
372
|
const run = useCallback((input, opts) => {
|
|
369
373
|
handleRef.current?.abort();
|
|
374
|
+
runIdRef.current = null;
|
|
370
375
|
let acc = initialAgentRunState();
|
|
371
376
|
let buffer = "";
|
|
372
377
|
let aborted = false;
|
|
@@ -382,6 +387,8 @@ export function useAgentRun(alias) {
|
|
|
382
387
|
for (const c of chunks)
|
|
383
388
|
acc = reduceAgentChunk(acc, c);
|
|
384
389
|
safeSetState({ ...acc });
|
|
390
|
+
}, (runId) => {
|
|
391
|
+
runIdRef.current = runId;
|
|
385
392
|
});
|
|
386
393
|
// Wrap abort so a stop (user or unmount) marks the run cancelled and clears
|
|
387
394
|
// the partial state back to idle — `done` resolves cleanly, never an error.
|
|
@@ -406,9 +413,27 @@ export function useAgentRun(alias) {
|
|
|
406
413
|
captureAppEvent("app_agent_run", { alias, ok: acc.status !== "error" });
|
|
407
414
|
return acc.output;
|
|
408
415
|
})
|
|
409
|
-
.catch((err) => {
|
|
416
|
+
.catch(async (err) => {
|
|
410
417
|
if (aborted)
|
|
411
418
|
return undefined;
|
|
419
|
+
// The stream connection dropped, but the run is decoupled from it and
|
|
420
|
+
// keeps executing server-side. Poll the persisted run to completion and
|
|
421
|
+
// surface its result instead of a network error — work is never lost.
|
|
422
|
+
const runId = runIdRef.current;
|
|
423
|
+
if (runId) {
|
|
424
|
+
const settled = await pollAgentRunToSettle(runId, () => aborted || !mountedRef.current);
|
|
425
|
+
if (settled && !aborted) {
|
|
426
|
+
acc =
|
|
427
|
+
settled.status === "completed"
|
|
428
|
+
? { ...acc, status: "completed", output: settled.output ?? acc.output }
|
|
429
|
+
: { ...acc, status: "error", error: settled.error_message ?? "The run was stopped." };
|
|
430
|
+
safeSetState(acc);
|
|
431
|
+
captureAppEvent("app_agent_run", { alias, ok: settled.status === "completed" });
|
|
432
|
+
return acc.output;
|
|
433
|
+
}
|
|
434
|
+
if (aborted)
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
412
437
|
acc = { ...acc, status: "error", error: err.message };
|
|
413
438
|
safeSetState(acc);
|
|
414
439
|
captureAppEvent("app_agent_run", { alias, ok: false });
|
|
@@ -416,9 +441,17 @@ export function useAgentRun(alias) {
|
|
|
416
441
|
});
|
|
417
442
|
}, [alias, safeSetState]);
|
|
418
443
|
const abort = useCallback(() => handleRef.current?.abort(), []);
|
|
444
|
+
const cancel = useCallback(() => {
|
|
445
|
+
// Stop server-side too (saves tokens on an unwanted run), then locally.
|
|
446
|
+
const runId = runIdRef.current;
|
|
447
|
+
if (runId)
|
|
448
|
+
void rpc("agentRun.cancel", { run_id: runId }).catch(() => { });
|
|
449
|
+
handleRef.current?.abort();
|
|
450
|
+
}, []);
|
|
419
451
|
return {
|
|
420
452
|
run,
|
|
421
453
|
abort,
|
|
454
|
+
cancel,
|
|
422
455
|
status: state?.status ?? "idle",
|
|
423
456
|
text: state?.text ?? "",
|
|
424
457
|
steps: state?.steps ?? [],
|
|
@@ -426,6 +459,29 @@ export function useAgentRun(alias) {
|
|
|
426
459
|
error: state?.error,
|
|
427
460
|
};
|
|
428
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* Poll a single run until it leaves `running` — the resume path when a run's
|
|
464
|
+
* stream connection drops mid-flight. Bounded just past the server-side max-run
|
|
465
|
+
* cap so a hung run can never poll forever, and bails the moment the caller
|
|
466
|
+
* aborts or unmounts. Returns the settled run, or null if it never settled.
|
|
467
|
+
*/
|
|
468
|
+
async function pollAgentRunToSettle(runId, cancelled) {
|
|
469
|
+
const deadline = Date.now() + 11 * 60_000;
|
|
470
|
+
while (Date.now() < deadline) {
|
|
471
|
+
await new Promise((r) => setTimeout(r, 2500));
|
|
472
|
+
if (cancelled())
|
|
473
|
+
return null;
|
|
474
|
+
try {
|
|
475
|
+
const { run } = await rpc("agentRun.get", { run_id: runId });
|
|
476
|
+
if (run.status !== "running")
|
|
477
|
+
return run;
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
// transient read failure — keep polling until the deadline
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
429
485
|
/**
|
|
430
486
|
* The run history of a session, oldest-first — the persisted outputs the app
|
|
431
487
|
* renders as a session log (NOT a chat: a flat list of past runs). Refetch
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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, AppAgents, AppAgentResults } from "./types.js";
|
|
35
|
+
export type { AppWorkflows, AppWorkflowResults, 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/rpc.d.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
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" | "agentRuns" | "upload" | "members" | "context" | "openExternal" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
|
|
21
|
+
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
|
|
22
22
|
/** Payload for starting a streaming agent run. */
|
|
23
23
|
export interface AgentRunPayload {
|
|
24
24
|
alias: string;
|
|
@@ -64,4 +64,4 @@ export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T
|
|
|
64
64
|
* ends. Bridged: the host opens the SSE with its session and forwards chunks;
|
|
65
65
|
* standalone: the SDK reads the public endpoint's body directly.
|
|
66
66
|
*/
|
|
67
|
-
export declare function rpcAgentRun(payload: AgentRunPayload, onText: (chunk: string) => void): AgentRunHandle;
|
|
67
|
+
export declare function rpcAgentRun(payload: AgentRunPayload, onText: (chunk: string) => void, onRunId?: (runId: string) => void): AgentRunHandle;
|
package/dist/src/rpc.js
CHANGED
|
@@ -48,11 +48,15 @@ function ensureListener() {
|
|
|
48
48
|
handler.reject(new Error(msg.message ?? "RPC failed"));
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
51
|
-
// Streaming op (agentRun): chunk* → end | error.
|
|
51
|
+
// Streaming op (agentRun): run-id? chunk* → end | error.
|
|
52
52
|
const stream = streaming.get(msg.id);
|
|
53
53
|
if (!stream)
|
|
54
54
|
return;
|
|
55
|
-
if (msg.type === "
|
|
55
|
+
if (msg.type === "run-id") {
|
|
56
|
+
if (typeof msg.runId === "string")
|
|
57
|
+
stream.onRunId?.(msg.runId);
|
|
58
|
+
}
|
|
59
|
+
else if (msg.type === "stream-chunk") {
|
|
56
60
|
if (typeof msg.chunk === "string")
|
|
57
61
|
stream.onText(msg.chunk);
|
|
58
62
|
}
|
|
@@ -81,17 +85,19 @@ function rpcBridged(op, payload, host) {
|
|
|
81
85
|
* ends. Bridged: the host opens the SSE with its session and forwards chunks;
|
|
82
86
|
* standalone: the SDK reads the public endpoint's body directly.
|
|
83
87
|
*/
|
|
84
|
-
export function rpcAgentRun(payload, onText) {
|
|
88
|
+
export function rpcAgentRun(payload, onText, onRunId) {
|
|
85
89
|
const hostOrigin = getHostOrigin();
|
|
86
|
-
return hostOrigin
|
|
90
|
+
return hostOrigin
|
|
91
|
+
? agentRunBridged(payload, onText, hostOrigin, onRunId)
|
|
92
|
+
: agentRunStandalone(payload, onText, onRunId);
|
|
87
93
|
}
|
|
88
|
-
function agentRunBridged(payload, onText, host) {
|
|
94
|
+
function agentRunBridged(payload, onText, host, onRunId) {
|
|
89
95
|
ensureListener();
|
|
90
96
|
const id = nextRpcId++;
|
|
91
97
|
let settleDone = () => { };
|
|
92
98
|
const done = new Promise((resolve, reject) => {
|
|
93
99
|
settleDone = resolve;
|
|
94
|
-
streaming.set(id, { onText, resolve, reject });
|
|
100
|
+
streaming.set(id, { onText, onRunId, resolve, reject });
|
|
95
101
|
window.parent.postMessage({ id, op: "agentRun", payload }, host);
|
|
96
102
|
});
|
|
97
103
|
return {
|
|
@@ -107,7 +113,7 @@ function agentRunBridged(payload, onText, host) {
|
|
|
107
113
|
},
|
|
108
114
|
};
|
|
109
115
|
}
|
|
110
|
-
function agentRunStandalone(payload, onText) {
|
|
116
|
+
function agentRunStandalone(payload, onText, onRunId) {
|
|
111
117
|
const controller = new AbortController();
|
|
112
118
|
const done = (async () => {
|
|
113
119
|
const { app_id } = await boot();
|
|
@@ -124,6 +130,9 @@ function agentRunStandalone(payload, onText) {
|
|
|
124
130
|
const text = await res.text().catch(() => "");
|
|
125
131
|
throw new Error(text || `HTTP ${res.status}`);
|
|
126
132
|
}
|
|
133
|
+
const runId = res.headers.get("x-app-agent-run-id");
|
|
134
|
+
if (runId)
|
|
135
|
+
onRunId?.(runId);
|
|
127
136
|
const reader = res.body.getReader();
|
|
128
137
|
const decoder = new TextDecoder();
|
|
129
138
|
try {
|
|
@@ -307,6 +316,10 @@ function rpcStandalone(op, payload) {
|
|
|
307
316
|
return standaloneWorkflow(payload);
|
|
308
317
|
case "agentRuns":
|
|
309
318
|
return standaloneAgentRuns(payload);
|
|
319
|
+
case "agentRun.get":
|
|
320
|
+
return standaloneAgentRunGet(payload);
|
|
321
|
+
case "agentRun.cancel":
|
|
322
|
+
return standaloneAgentRunCancel(payload);
|
|
310
323
|
case "upload":
|
|
311
324
|
return standaloneUpload(payload.file);
|
|
312
325
|
case "members":
|
|
@@ -402,6 +415,24 @@ async function standaloneAgentRuns(p) {
|
|
|
402
415
|
}));
|
|
403
416
|
return { runs: r.runs ?? [] };
|
|
404
417
|
}
|
|
418
|
+
/** Poll read for a single run — follows a run to completion after its stream
|
|
419
|
+
* connection drops. The caller types the run shape via `rpc<T>`. */
|
|
420
|
+
async function standaloneAgentRunGet(p) {
|
|
421
|
+
const { app_id } = await boot();
|
|
422
|
+
const r = (await apiCall("GET", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}`, undefined, {
|
|
423
|
+
appId: app_id,
|
|
424
|
+
}));
|
|
425
|
+
return { run: r.run };
|
|
426
|
+
}
|
|
427
|
+
/** Request server-side cancellation of an in-flight run (an explicit user stop,
|
|
428
|
+
* distinct from merely closing the stream). */
|
|
429
|
+
async function standaloneAgentRunCancel(p) {
|
|
430
|
+
const { app_id } = await boot();
|
|
431
|
+
await apiCall("POST", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}/cancel`, {}, {
|
|
432
|
+
appId: app_id,
|
|
433
|
+
});
|
|
434
|
+
return { ok: true };
|
|
435
|
+
}
|
|
405
436
|
async function standaloneUpload(file) {
|
|
406
437
|
if (!(file instanceof File)) {
|
|
407
438
|
throw new Error("upload payload must include a File");
|