@lotics/app-sdk 0.51.5 → 0.52.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 +1 -1
- package/dist/src/hooks.d.ts +36 -0
- package/dist/src/hooks.js +89 -2
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/rpc.d.ts +45 -0
- package/dist/src/rpc.js +38 -0
- package/docs/ai.md +64 -1
- package/docs/data_fetching.md +6 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -20,7 +20,7 @@ signature; open the file.**
|
|
|
20
20
|
| [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs, workflow-generated files, preview pairing, filter operators, the server-side delivery bounds. |
|
|
21
21
|
| [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
|
|
22
22
|
| [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
|
|
23
|
-
| [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming `items` → `AgentRun`)
|
|
23
|
+
| [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming `items` → `AgentRun`), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). |
|
|
24
24
|
| [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen. |
|
|
25
25
|
| [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, `openExternal`/`downloadFile`, geofencing, analytics, `useConfig` (App-Packages installation config), `getAppBinding` (package apps' runtime `F`/`OPT`/`ROLE` resolution via the generated `.lotics/app_fields.ts`), and the publish chain for package contributors. |
|
|
26
26
|
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AiContextValue } from "./rpc.js";
|
|
1
2
|
import { type AgentRunStep, type AgentRunItem } from "./agent_stream.js";
|
|
2
3
|
import type { AppWorkflows, AppWorkflowResults, AppQueries, AppAgents, AppAgentResults } from "./types.js";
|
|
3
4
|
import type { ResolvedMember } from "./members.js";
|
|
@@ -372,6 +373,41 @@ interface AttachmentsState {
|
|
|
372
373
|
* ```
|
|
373
374
|
*/
|
|
374
375
|
export declare function useAttachments(): AttachmentsState;
|
|
376
|
+
/**
|
|
377
|
+
* Publish a slice of the CURRENT SCREEN's view state to the app's ambient chat
|
|
378
|
+
* agent, so a member chatting alongside the app gets an agent that knows what
|
|
379
|
+
* they are looking at — which list is filtered to what, which record is open,
|
|
380
|
+
* what is typed into a form. Declarative and lifecycle-bound: mounting or
|
|
381
|
+
* changing `context` pushes it to the host; unmounting, renaming the `slot`, or
|
|
382
|
+
* passing `null` clears it. Independent components may publish different `slot`s
|
|
383
|
+
* concurrently (a list screen + an open detail drawer); the newest value per
|
|
384
|
+
* slot wins.
|
|
385
|
+
*
|
|
386
|
+
* **Push-only, and a SNAPSHOT of what the app already RENDERED to this member** —
|
|
387
|
+
* never a channel for chat to pull app-authority data. `records` are passed as
|
|
388
|
+
* raw `{ table_id, record_id }` refs (unresolved); the member's own chat agent
|
|
389
|
+
* acts on them only where that member's IAM already allows. The host feeds
|
|
390
|
+
* `description`/`data` into the agent's prompt as clearly-labeled DATA, never as
|
|
391
|
+
* instructions.
|
|
392
|
+
*
|
|
393
|
+
* Host-enforced caps (exceeding them truncates/drops — never an error): `slot`
|
|
394
|
+
* ≤ 50 chars; `description` ≤ 1000 chars (truncated with "…"); `records` ≤ 20;
|
|
395
|
+
* `data` must JSON-serialize to ≤ 2000 chars or the `data` field is dropped (the
|
|
396
|
+
* description is kept); ≤ 8 slots per app (a 9th evicts the least-recently
|
|
397
|
+
* updated).
|
|
398
|
+
*
|
|
399
|
+
* ```tsx
|
|
400
|
+
* useAiContext("orders_list", {
|
|
401
|
+
* description: `Viewing ${rows.length} orders filtered to status=open, sorted by due date.`,
|
|
402
|
+
* records: rows.map((r) => ({ table_id: r.__source_table_id, record_id: r.__source_record_id })),
|
|
403
|
+
* data: { filter: "status=open", sort: "due_date desc" },
|
|
404
|
+
* });
|
|
405
|
+
* ```
|
|
406
|
+
*
|
|
407
|
+
* No-ops with no embedding host (standalone `<slug>.lotics.app` — there is no
|
|
408
|
+
* chat surface to inform) and in mock mode. Since 0.52.
|
|
409
|
+
*/
|
|
410
|
+
export declare function useAiContext(slot: string, context: AiContextValue | null): void;
|
|
375
411
|
interface MembersState {
|
|
376
412
|
/** Members of the app's organization, for assign / member-picker UIs. */
|
|
377
413
|
members: ResolvedMember[];
|
package/dist/src/hooks.js
CHANGED
|
@@ -18,9 +18,9 @@
|
|
|
18
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, rpcAgentRun } from "./rpc.js";
|
|
21
|
+
import { rpc, rpcAgentRun, postHostNotification, subscribeHostRefetch, } from "./rpc.js";
|
|
22
22
|
import { initialAgentRunState, reduceAgentChunk, parseSseChunks, } from "./agent_stream.js";
|
|
23
|
-
import { getMockRows } from "./mock.js";
|
|
23
|
+
import { getMockRows, hasMockFlag } from "./mock.js";
|
|
24
24
|
import { captureAppEvent } from "./analytics.js";
|
|
25
25
|
export function useWorkflow(alias) {
|
|
26
26
|
return useCallback((inputs) => rpc("workflow", { alias, inputs: inputs ?? {} }), [alias]);
|
|
@@ -35,6 +35,19 @@ function swrConfig(revalidateOnFocus) {
|
|
|
35
35
|
shouldRetryOnError: false,
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Re-run `refetch` when the host pushes `refetchQueries` — after an ambient
|
|
40
|
+
* app-chat agent turn mutated records, every mounted query hook refreshes the
|
|
41
|
+
* data it rendered. Inert in mock mode (no host, no listener) and for a mocked
|
|
42
|
+
* alias (its rows come from the fixture). Shared by all three query hooks.
|
|
43
|
+
*/
|
|
44
|
+
function useHostRefetch(refetch, mockRows) {
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (hasMockFlag() || mockRows)
|
|
47
|
+
return;
|
|
48
|
+
return subscribeHostRefetch(refetch);
|
|
49
|
+
}, [refetch, mockRows]);
|
|
50
|
+
}
|
|
38
51
|
export function useQuery(alias, params, opts) {
|
|
39
52
|
const pageSize = opts?.pageSize;
|
|
40
53
|
const enabled = opts?.enabled ?? true;
|
|
@@ -59,6 +72,7 @@ export function useQuery(alias, params, opts) {
|
|
|
59
72
|
const refetch = useCallback(() => {
|
|
60
73
|
void swr.mutate();
|
|
61
74
|
}, [swr]);
|
|
75
|
+
useHostRefetch(refetch, mockRows);
|
|
62
76
|
return {
|
|
63
77
|
rows: mockRows ?? swr.data?.rows ?? [],
|
|
64
78
|
loading: mockRows ? false : swr.isLoading,
|
|
@@ -139,6 +153,7 @@ export function useInfiniteQuery(alias, params, opts) {
|
|
|
139
153
|
const refetch = useCallback(() => {
|
|
140
154
|
void swr.mutate();
|
|
141
155
|
}, [swr]);
|
|
156
|
+
useHostRefetch(refetch, mockRows);
|
|
142
157
|
const loadMore = useCallback(() => {
|
|
143
158
|
if (!hasMore || loadingMore)
|
|
144
159
|
return;
|
|
@@ -196,6 +211,7 @@ export function usePaginatedQuery(alias, params, opts) {
|
|
|
196
211
|
void rowsSwr.mutate();
|
|
197
212
|
void countSwr.mutate();
|
|
198
213
|
}, [rowsSwr, countSwr]);
|
|
214
|
+
useHostRefetch(refetch, mockRows);
|
|
199
215
|
return {
|
|
200
216
|
rows,
|
|
201
217
|
total,
|
|
@@ -302,6 +318,77 @@ export function useAttachments() {
|
|
|
302
318
|
const fileIds = files.flatMap((f) => (f.status === "ready" && f.file_id ? [f.file_id] : []));
|
|
303
319
|
return { files, add, remove, clear, uploading, fileIds };
|
|
304
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* JSON-serialize the view-state snapshot — used for BOTH change-detection (a
|
|
323
|
+
* fresh inline object each render must NOT re-post) and the wire payload. The
|
|
324
|
+
* `data` field is app-supplied `unknown`; if it can't be JSON-serialized we drop
|
|
325
|
+
* it and keep description + records, mirroring the host's own `data` cap (which
|
|
326
|
+
* JSON-encodes `data` and drops it past the size limit) — a non-serializable
|
|
327
|
+
* value must never crash the app's render.
|
|
328
|
+
*/
|
|
329
|
+
function serializeAiContext(context) {
|
|
330
|
+
try {
|
|
331
|
+
return JSON.stringify(context);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return JSON.stringify({ description: context.description, records: context.records });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Publish a slice of the CURRENT SCREEN's view state to the app's ambient chat
|
|
339
|
+
* agent, so a member chatting alongside the app gets an agent that knows what
|
|
340
|
+
* they are looking at — which list is filtered to what, which record is open,
|
|
341
|
+
* what is typed into a form. Declarative and lifecycle-bound: mounting or
|
|
342
|
+
* changing `context` pushes it to the host; unmounting, renaming the `slot`, or
|
|
343
|
+
* passing `null` clears it. Independent components may publish different `slot`s
|
|
344
|
+
* concurrently (a list screen + an open detail drawer); the newest value per
|
|
345
|
+
* slot wins.
|
|
346
|
+
*
|
|
347
|
+
* **Push-only, and a SNAPSHOT of what the app already RENDERED to this member** —
|
|
348
|
+
* never a channel for chat to pull app-authority data. `records` are passed as
|
|
349
|
+
* raw `{ table_id, record_id }` refs (unresolved); the member's own chat agent
|
|
350
|
+
* acts on them only where that member's IAM already allows. The host feeds
|
|
351
|
+
* `description`/`data` into the agent's prompt as clearly-labeled DATA, never as
|
|
352
|
+
* instructions.
|
|
353
|
+
*
|
|
354
|
+
* Host-enforced caps (exceeding them truncates/drops — never an error): `slot`
|
|
355
|
+
* ≤ 50 chars; `description` ≤ 1000 chars (truncated with "…"); `records` ≤ 20;
|
|
356
|
+
* `data` must JSON-serialize to ≤ 2000 chars or the `data` field is dropped (the
|
|
357
|
+
* description is kept); ≤ 8 slots per app (a 9th evicts the least-recently
|
|
358
|
+
* updated).
|
|
359
|
+
*
|
|
360
|
+
* ```tsx
|
|
361
|
+
* useAiContext("orders_list", {
|
|
362
|
+
* description: `Viewing ${rows.length} orders filtered to status=open, sorted by due date.`,
|
|
363
|
+
* records: rows.map((r) => ({ table_id: r.__source_table_id, record_id: r.__source_record_id })),
|
|
364
|
+
* data: { filter: "status=open", sort: "due_date desc" },
|
|
365
|
+
* });
|
|
366
|
+
* ```
|
|
367
|
+
*
|
|
368
|
+
* No-ops with no embedding host (standalone `<slug>.lotics.app` — there is no
|
|
369
|
+
* chat surface to inform) and in mock mode. Since 0.52.
|
|
370
|
+
*/
|
|
371
|
+
export function useAiContext(slot, context) {
|
|
372
|
+
// Serialize once for both the change key and the payload. The effect re-posts
|
|
373
|
+
// only when the serialized value (or slot) actually changes, so a fresh inline
|
|
374
|
+
// object each render never spams the host. Payloads are small (host-capped),
|
|
375
|
+
// so the JSON round-trip is cheap.
|
|
376
|
+
const serialized = context === null ? null : serializeAiContext(context);
|
|
377
|
+
// Post the current value on mount and whenever it changes.
|
|
378
|
+
useEffect(() => {
|
|
379
|
+
if (hasMockFlag())
|
|
380
|
+
return;
|
|
381
|
+
const payload = serialized === null ? null : JSON.parse(serialized);
|
|
382
|
+
postHostNotification({ type: "aiContext", slot, context: payload });
|
|
383
|
+
}, [slot, serialized]);
|
|
384
|
+
// Clear the slot on unmount or slot rename ONLY — a value-only change is
|
|
385
|
+
// overwritten by the post above, with no intermediate clear.
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (hasMockFlag())
|
|
388
|
+
return;
|
|
389
|
+
return () => postHostNotification({ type: "aiContext", slot, context: null });
|
|
390
|
+
}, [slot]);
|
|
391
|
+
}
|
|
305
392
|
/**
|
|
306
393
|
* List the members of the app's organization — the candidate set for an
|
|
307
394
|
* "assign to a member" picker. Each member is `{ id, name, email, image }`
|
package/dist/src/index.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
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, useAttachments, useMembers, useAgentRun, useAgentRuns, } from "./hooks.js";
|
|
19
|
+
export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, } from "./hooks.js";
|
|
20
20
|
export type { UploadedFile, AttachedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunRecord, AgentRunState, AgentRunStep, AgentRunItem, 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";
|
|
@@ -26,7 +26,7 @@ export type { AppConfigValue } from "./rpc.js";
|
|
|
26
26
|
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
|
27
27
|
export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
|
|
28
28
|
export { rpc, isEmbedded, getAppBinding } from "./rpc.js";
|
|
29
|
-
export type { RpcOp, AppBinding } from "./rpc.js";
|
|
29
|
+
export type { RpcOp, AppBinding, AiContextValue, AiContextRecordRef } from "./rpc.js";
|
|
30
30
|
export { openExternal } from "./open_external.js";
|
|
31
31
|
export { askAi, type AskAiArgs } from "./ask_ai.js";
|
|
32
32
|
export { downloadFile } from "./download.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, useAttachments, useMembers, useAgentRun, useAgentRuns, } from "./hooks.js";
|
|
18
|
+
export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, } from "./hooks.js";
|
|
19
19
|
export { useComments, useCommentCounts } from "./comments.js";
|
|
20
20
|
export { useViewer } from "./viewer.js";
|
|
21
21
|
export { useConfig } from "./config.js";
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -54,6 +54,37 @@ export interface AgentRunHandle {
|
|
|
54
54
|
* mirror, like `AppContext` itself).
|
|
55
55
|
*/
|
|
56
56
|
export type AppConfigValue = string | number | boolean;
|
|
57
|
+
/** A raw reference to one record — table + record id, passed through UNRESOLVED.
|
|
58
|
+
* The member's own chat agent may act on it only where that member's IAM already
|
|
59
|
+
* allows; the app never resolves it into data here. */
|
|
60
|
+
export interface AiContextRecordRef {
|
|
61
|
+
table_id: string;
|
|
62
|
+
record_id: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A snapshot of what the app RENDERED to this member on the current screen —
|
|
66
|
+
* published to the ambient app chat agent via `useAiContext` so the member's
|
|
67
|
+
* agent knows what they are looking at. Push-only: this is data the app already
|
|
68
|
+
* showed, never a channel for chat to pull app-authority data back out.
|
|
69
|
+
*/
|
|
70
|
+
export interface AiContextValue {
|
|
71
|
+
/** Human-readable summary of the view — enters the agent prompt as labeled
|
|
72
|
+
* DATA (never instructions). Host-capped to 1000 chars. */
|
|
73
|
+
description: string;
|
|
74
|
+
/** Records the screen is showing, as raw `{ table_id, record_id }` refs
|
|
75
|
+
* (unresolved). Host-capped to 20. */
|
|
76
|
+
records?: AiContextRecordRef[];
|
|
77
|
+
/** Optional structured detail (filters, sort, form values). Host JSON-encodes
|
|
78
|
+
* it and DROPS the field past its size cap, keeping the description. */
|
|
79
|
+
data?: unknown;
|
|
80
|
+
}
|
|
81
|
+
/** A fire-and-forget notification the app pushes UP to its embedding host — no
|
|
82
|
+
* id, no reply, distinct from the request/reply `rpc()` bridge. */
|
|
83
|
+
export type HostNotification = {
|
|
84
|
+
type: "aiContext";
|
|
85
|
+
slot: string;
|
|
86
|
+
context: AiContextValue | null;
|
|
87
|
+
};
|
|
57
88
|
export interface AppContext {
|
|
58
89
|
app_id: string;
|
|
59
90
|
app_name: string;
|
|
@@ -95,6 +126,20 @@ export declare function peekUrlParams(): UrlParams;
|
|
|
95
126
|
/** Subscribe to external query changes — browser back/forward and edited URLs.
|
|
96
127
|
* Embedded: the host's `url-state` broadcast; standalone: `popstate`. */
|
|
97
128
|
export declare function subscribeUrlParams(cb: (params: UrlParams) => void): () => void;
|
|
129
|
+
/**
|
|
130
|
+
* Push a fire-and-forget notification to the embedding host — no id, no reply.
|
|
131
|
+
* Only the embedded host can receive it (it owns the chat surface), so this
|
|
132
|
+
* no-ops standalone (`<slug>.lotics.app` has no host to inform) and never
|
|
133
|
+
* throws. Distinct from `rpc()`: this is one-way, app → host.
|
|
134
|
+
*/
|
|
135
|
+
export declare function postHostNotification(message: HostNotification): void;
|
|
136
|
+
/**
|
|
137
|
+
* Subscribe to the host's `refetchQueries` push. Returns an unsubscribe fn.
|
|
138
|
+
* Wired by every mounted query hook so an ambient-chat record mutation refreshes
|
|
139
|
+
* exactly the data currently on screen. No host ever posts it standalone, so the
|
|
140
|
+
* subscription is inert there.
|
|
141
|
+
*/
|
|
142
|
+
export declare function subscribeHostRefetch(cb: () => void): () => void;
|
|
98
143
|
/**
|
|
99
144
|
* Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
|
|
100
145
|
* (the caller parses it via `agent_stream`); `done` settles when the stream
|
package/dist/src/rpc.js
CHANGED
|
@@ -78,11 +78,40 @@ export function subscribeUrlParams(cb) {
|
|
|
78
78
|
window.addEventListener("popstate", handler);
|
|
79
79
|
return () => window.removeEventListener("popstate", handler);
|
|
80
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 (`<slug>.lotics.app` 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 `refetchQueries` push. Returns an unsubscribe fn.
|
|
96
|
+
* Wired by every mounted query hook so an ambient-chat record mutation refreshes
|
|
97
|
+
* exactly the data currently on screen. No host ever posts it standalone, so the
|
|
98
|
+
* subscription is inert there.
|
|
99
|
+
*/
|
|
100
|
+
export function subscribeHostRefetch(cb) {
|
|
101
|
+
ensureListener();
|
|
102
|
+
refetchSubscribers.add(cb);
|
|
103
|
+
return () => {
|
|
104
|
+
refetchSubscribers.delete(cb);
|
|
105
|
+
};
|
|
106
|
+
}
|
|
81
107
|
const pending = new Map();
|
|
82
108
|
const streaming = new Map();
|
|
83
109
|
/** `useUrlState` subscribers — notified when the host broadcasts new params
|
|
84
110
|
* after browser back/forward. */
|
|
85
111
|
const urlStateSubscribers = new Set();
|
|
112
|
+
/** Query hooks subscribed to the host's `refetchQueries` push — the host sends
|
|
113
|
+
* it after an ambient app-chat agent turn mutated records. */
|
|
114
|
+
const refetchSubscribers = new Set();
|
|
86
115
|
let nextRpcId = 0;
|
|
87
116
|
let listenerInstalled = false;
|
|
88
117
|
function ensureListener() {
|
|
@@ -103,6 +132,15 @@ function ensureListener() {
|
|
|
103
132
|
cb(msg.params);
|
|
104
133
|
return;
|
|
105
134
|
}
|
|
135
|
+
// Broadcast (no id): the host tells the app its rendered data may be stale
|
|
136
|
+
// after an ambient app-chat agent turn mutated records — every mounted query
|
|
137
|
+
// hook re-reads through its own declared queries. Push-only freshness; the
|
|
138
|
+
// host never reads app data through this path.
|
|
139
|
+
if (msg.type === "refetchQueries") {
|
|
140
|
+
for (const cb of refetchSubscribers)
|
|
141
|
+
cb();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
106
144
|
if (typeof msg.id !== "number")
|
|
107
145
|
return;
|
|
108
146
|
// Single-response ops.
|
package/docs/ai.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AI in apps
|
|
2
2
|
|
|
3
|
-
An app has two AI surfaces, and they answer different questions. **`useAgentRun(alias)`** runs an agent *declared on the app* — a streaming, tool-looping run whose result lands back **in the app** (a typed structured output, or free-text prose) for the app to review and commit through its own [workflows](./mutations.md). **`askAi(args)`** is a *handoff* — it opens the Lotics chat messenger seeded with files, records, and a prefilled prompt, and the outcome lands **in chat**, under the signed-in member's control. Read this doc when adding any AI-driven feature to an app; read [security](./security.md) first for the authority model agent runs execute under. Exact signatures: `dist/src/hooks.d.ts` (`useAgentRun`, `useAgentRuns`), `dist/src/agent_stream.d.ts` (`AgentRunItem`, `AgentRunStep`, `AgentRunState`), `dist/src/ask_ai.d.ts` (`AskAiArgs`).
|
|
3
|
+
An app has two AI surfaces, and they answer different questions. **`useAgentRun(alias)`** runs an agent *declared on the app* — a streaming, tool-looping run whose result lands back **in the app** (a typed structured output, or free-text prose) for the app to review and commit through its own [workflows](./mutations.md). **`askAi(args)`** is a *handoff* — it opens the Lotics chat messenger seeded with files, records, and a prefilled prompt, and the outcome lands **in chat**, under the signed-in member's control. Beyond those two, **`useAiContext(slot, context)`** feeds the member's *ambient* chat agent — the one riding alongside the app — a snapshot of what the current screen is showing, so a question the member asks there resolves against what they're looking at. Read this doc when adding any AI-driven feature to an app; read [security](./security.md) first for the authority model agent runs execute under. Exact signatures: `dist/src/hooks.d.ts` (`useAgentRun`, `useAgentRuns`, `useAiContext`), `dist/src/agent_stream.d.ts` (`AgentRunItem`, `AgentRunStep`, `AgentRunState`), `dist/src/ask_ai.d.ts` (`AskAiArgs`).
|
|
4
4
|
|
|
5
5
|
## Choosing the surface — the fields-vs-file razor
|
|
6
6
|
|
|
@@ -189,6 +189,68 @@ At least one of the four is required — an empty call rejects. The bridge carri
|
|
|
189
189
|
|
|
190
190
|
---
|
|
191
191
|
|
|
192
|
+
## `useAiContext(slot, context)` — tell the ambient chat what the member is looking at
|
|
193
|
+
|
|
194
|
+
A member using an app inside Lotics has an **ambient chat agent** riding alongside the app (distinct from `askAi`, which opens a fresh seeded chat, and from `useAgentRun`, which runs an agent the app declares). `useAiContext` publishes a snapshot of the **current screen's view state** into that ambient chat, so when the member turns to it and asks "why is this one overdue?" or "summarize what I'm seeing", the agent already knows which list is filtered to what, which record is open, and what's typed into a form — without the member re-describing it.
|
|
195
|
+
|
|
196
|
+
```tsx
|
|
197
|
+
import { useAiContext } from "@lotics/app-sdk";
|
|
198
|
+
|
|
199
|
+
// A list screen publishes what it rendered:
|
|
200
|
+
useAiContext("orders_list", {
|
|
201
|
+
description: `Viewing ${rows.length} orders filtered to status=open, sorted by due date.`,
|
|
202
|
+
records: rows.map((r) => ({ table_id: r.__source_table_id, record_id: r.__source_record_id })),
|
|
203
|
+
data: { filter: "status=open", sort: "due_date desc" },
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// A detail drawer publishes its own slot; clears it when nothing is selected:
|
|
207
|
+
useAiContext(
|
|
208
|
+
"order_detail",
|
|
209
|
+
selected
|
|
210
|
+
? { description: `Order ${selected.code}, status ${selected.status}`, records: [{ table_id: "tbl_orders", record_id: selected.id }] }
|
|
211
|
+
: null,
|
|
212
|
+
);
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### When to use it
|
|
216
|
+
|
|
217
|
+
- **List / register screens** — the filtered, sorted set the member is scanning: a one-line `description` of the filter + count, and the visible rows as `records`.
|
|
218
|
+
- **Detail / record views** — which record is open, its key fields; pass the one record ref.
|
|
219
|
+
- **Form / composer state** — what the member has entered so far (`data`), so a mid-task question resolves against the draft.
|
|
220
|
+
|
|
221
|
+
Publish the **rendered** view, not the whole table. A screen that shows page 2 of an open-orders filter publishes those rows — that is exactly what "what I'm looking at" means.
|
|
222
|
+
|
|
223
|
+
### Lifecycle and slots
|
|
224
|
+
|
|
225
|
+
Declarative and lifecycle-bound: mounting or changing `context` pushes it; unmounting, renaming the `slot`, or passing `null` clears it. Independent components hold **different slots** at once (a list screen + an open drawer), and the newest value per slot wins. Re-posting is change-gated — passing a fresh inline object each render does **not** spam the host; only a real value change re-publishes.
|
|
226
|
+
|
|
227
|
+
### The caps (host-enforced — exceeding them truncates or drops, never errors)
|
|
228
|
+
|
|
229
|
+
| Field | Cap |
|
|
230
|
+
|---|---|
|
|
231
|
+
| `slot` | ≤ 50 chars |
|
|
232
|
+
| `description` | ≤ 1000 chars (truncated with `…`) |
|
|
233
|
+
| `records` | ≤ 20 refs |
|
|
234
|
+
| `data` | must JSON-serialize to ≤ 2000 chars, else the `data` field is **dropped** (the description is kept) |
|
|
235
|
+
| slots per app | ≤ 8 (a 9th evicts the least-recently-updated) |
|
|
236
|
+
|
|
237
|
+
Keep `description` tight and human-readable — it is the line the agent reads. Put anything structured (filter/sort/form values) in `data`, and remember it is dropped whole past its size cap, so don't hide load-bearing facts there that aren't also in `description`.
|
|
238
|
+
|
|
239
|
+
### Security — push-only, a snapshot of what was already rendered
|
|
240
|
+
|
|
241
|
+
This is a **one-way push of data the app already showed this member** — never a channel for chat to pull app-authority data back out. Two consequences hold it to that:
|
|
242
|
+
|
|
243
|
+
- **`records` are raw `{ table_id, record_id }` refs, passed UNRESOLVED.** The app does not resolve them here; the member's own chat agent may read or act on them only where **that member's IAM already allows**. A ref to a record the member can't see stays inert.
|
|
244
|
+
- **`description` and `data` enter the agent's prompt as clearly-labeled DATA, never as instructions.** Text an app renders can't hijack the agent — the host wraps it as app-supplied view state (see [security](./security.md) for the labeled-data convention).
|
|
245
|
+
|
|
246
|
+
### Query freshness — the mutation companion
|
|
247
|
+
|
|
248
|
+
When the ambient chat agent's turn ends and it mutated records, the host pushes every mounted query hook to re-read, so the screen the member is looking at reflects the agent's change without a manual refresh. That companion behavior is automatic — you write no code for it — and is documented with the query caching contract in [data_fetching](./data_fetching.md#caching-loading-states-and-errors).
|
|
249
|
+
|
|
250
|
+
**No-ops** with no embedding host (standalone `<slug>.lotics.app` — there's no chat surface to inform) and in mock mode.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
192
254
|
## Version floors
|
|
193
255
|
|
|
194
256
|
The floors below are when each capability shipped in `@lotics/app-sdk`; an app pinned older silently lacks them.
|
|
@@ -200,3 +262,4 @@ The floors below are when each capability shipped in `@lotics/app-sdk`; an app p
|
|
|
200
262
|
| `items` transcript (reasoning segments + per-tool `input`/`output`) | `@lotics/app-sdk` 0.43; rendering pairs with `@lotics/ui` ≥ 7.13 (`AgentRun` `items` prop) |
|
|
201
263
|
| Live streamed-argument size on a running step (`detail`) | `@lotics/app-sdk` 0.44 |
|
|
202
264
|
| `askAi` | `@lotics/app-sdk` 0.45 |
|
|
265
|
+
| `useAiContext` (ambient-chat view state + auto query refetch on chat mutation) | `@lotics/app-sdk` 0.52 |
|
package/docs/data_fetching.md
CHANGED
|
@@ -83,6 +83,12 @@ server validates system conditions by `type` and never reads `field_key` on them
|
|
|
83
83
|
- **`refetch()`** re-runs the query. Call it after a known mutation point — a successful
|
|
84
84
|
`useWorkflow` call — to pull the latest state (see [./mutations.md](./mutations.md)).
|
|
85
85
|
`usePaginatedQuery.refetch()` refreshes both the page and the count.
|
|
86
|
+
- **Ambient-chat mutations refetch automatically.** When the member's ambient chat agent (see
|
|
87
|
+
[./ai.md](./ai.md#useaicontextslot-context--tell-the-ambient-chat-what-the-member-is-looking-at))
|
|
88
|
+
finishes a turn that mutated records, the host pushes **every mounted query hook** to re-read —
|
|
89
|
+
so the screen reflects the change with no `refetch()` call and no reload. It refreshes exactly the
|
|
90
|
+
queries currently on screen (mounted hooks only); it never reaches into app data, it only tells
|
|
91
|
+
the app its rendered rows may be stale. Inert standalone and in mock mode.
|
|
86
92
|
- A design-time fixture registered via `mount(<App />, { fixture })` plus the `?__mock=1` URL flag
|
|
87
93
|
short-circuits all three hooks (rows come from the fixture, no request, `loading` stays `false`)
|
|
88
94
|
— see [./runtime.md](./runtime.md).
|