@lotics/app-sdk 0.52.4 → 0.54.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/agent_stream.d.ts +18 -53
- package/dist/src/agent_stream.js +59 -66
- package/dist/src/hooks.d.ts +11 -16
- package/dist/src/hooks.js +7 -9
- package/dist/src/index.d.ts +1 -1
- package/dist/src/row.d.ts +7 -3
- package/dist/src/row.js +13 -7
- package/docs/ai.md +16 -17
- package/docs/data_fetching.md +12 -4
- package/docs/mutations.md +53 -16
- package/package.json +5 -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 `
|
|
23
|
+
| [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `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
|
|
|
@@ -3,63 +3,28 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The backend streams the AI-SDK "UI message" SSE protocol (the same wire format
|
|
5
5
|
* the chat uses) — `data: <json>\n\n` frames, each a typed chunk, terminated by
|
|
6
|
-
* `data: [DONE]`.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* `data: [DONE]`. We fold the chunk types that matter into an ordered `parts` array
|
|
7
|
+
* — the SAME ai-sdk `UIMessagePart[]` shape `@lotics/ui`'s `AgentRun` renders, so
|
|
8
|
+
* `<AgentRun parts={run.parts} />` needs no adapter. `ai` is a TYPE-ONLY import here
|
|
9
|
+
* (tool calls are hand-built as concrete `dynamic-tool` parts); NO `ai` runtime
|
|
10
|
+
* enters the sandboxed app bundle. Unknown chunk types are ignored, so a newer
|
|
11
|
+
* AI-SDK never breaks the SDK.
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* injected `submit_result` tool → `output`; a FREE-TEXT agent's result is its prose
|
|
17
|
-
* → the transcript's text. `output` is NEVER the free-text (see
|
|
18
|
-
* `AgentRunState.output`). Pure + reducer-shaped so it is fully unit-testable
|
|
19
|
-
* against recorded frames — no live model needed.
|
|
13
|
+
* An agent can be either kind: a STRUCTURED agent (declares `outputs`) emits its
|
|
14
|
+
* result as the input of the injected `submit_result` tool → `output` (NOT a part);
|
|
15
|
+
* a FREE-TEXT agent's result is its prose → a text part. `output` is NEVER the
|
|
16
|
+
* free-text (see `AgentRunState.output`). Pure + reducer-shaped so it is fully
|
|
17
|
+
* unit-testable against recorded frames — no live model needed.
|
|
20
18
|
*/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
detail?: string;
|
|
25
|
-
status: "running" | "done" | "error";
|
|
26
|
-
kind?: "step" | "tool";
|
|
27
|
-
/** The tool call's input (arguments). Carried for on-demand reveal, NOT shown in
|
|
28
|
-
* the feed — `@lotics/ui` `AgentRun` renders it in a press-to-open peek. */
|
|
29
|
-
input?: unknown;
|
|
30
|
-
/** The tool call's result, once it returns. On-demand reveal, same as `input`. */
|
|
31
|
-
output?: unknown;
|
|
32
|
-
/** The tool failure message, when `status` is `"error"`. */
|
|
33
|
-
errorText?: string;
|
|
34
|
-
/** Running count of streamed argument characters. While a tool call's arguments
|
|
35
|
-
* are still generating, the reducer writes a live size into `detail` ("18 KB")
|
|
36
|
-
* off this accumulator, so a long generation reads as progressing, not frozen;
|
|
37
|
-
* both are cleared when the call settles. */
|
|
38
|
-
streamedChars?: number;
|
|
39
|
-
}
|
|
40
|
-
/** One entry in the ordered run transcript, in the order it streamed: the agent's
|
|
41
|
-
* answer prose (`text`), its thinking (`reasoning` — shown collapsed, revealed on
|
|
42
|
-
* demand), or a tool it ran (`step`). This is structurally the `AgentRunItem` that
|
|
43
|
-
* `@lotics/ui`'s `AgentRun` takes, so `<AgentRun items={run.items} state={…} />`
|
|
44
|
-
* needs no adapter — the SDK can't import the UI type (it must stay off the
|
|
45
|
-
* react-native-web dep tree), it mirrors the shape. */
|
|
46
|
-
export type AgentRunItem = {
|
|
47
|
-
type: "text";
|
|
48
|
-
id: string;
|
|
49
|
-
text: string;
|
|
50
|
-
} | {
|
|
51
|
-
type: "reasoning";
|
|
52
|
-
id: string;
|
|
53
|
-
text: string;
|
|
54
|
-
} | ({
|
|
55
|
-
type: "step";
|
|
56
|
-
} & AgentRunStep);
|
|
19
|
+
import type { UIMessagePart, UIDataTypes, UITools } from "ai";
|
|
20
|
+
/** An ai-sdk message part — the render model, tool-set-agnostic. */
|
|
21
|
+
export type AgentUIPart = UIMessagePart<UIDataTypes, UITools>;
|
|
57
22
|
export interface AgentRunState {
|
|
58
23
|
status: "streaming" | "completed" | "error";
|
|
59
|
-
/** The ordered transcript — prose interleaved with the tool
|
|
60
|
-
* streamed. The single source of truth for
|
|
61
|
-
*
|
|
62
|
-
|
|
24
|
+
/** The ordered transcript as ai-sdk `parts` — prose interleaved with the tool
|
|
25
|
+
* calls (`dynamic-tool` parts), as it streamed. The single source of truth for
|
|
26
|
+
* the feed; `useAgentRun` derives `text` / `steps` from it. */
|
|
27
|
+
parts: AgentUIPart[];
|
|
63
28
|
/** The structured result — the `submit_result` tool's input — or `undefined`.
|
|
64
29
|
* NEVER the free-text answer: a free-text agent (or a structured one that
|
|
65
30
|
* finished without submitting) has no `output`; its answer is the transcript's
|
package/dist/src/agent_stream.js
CHANGED
|
@@ -3,20 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The backend streams the AI-SDK "UI message" SSE protocol (the same wire format
|
|
5
5
|
* the chat uses) — `data: <json>\n\n` frames, each a typed chunk, terminated by
|
|
6
|
-
* `data: [DONE]`.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* `data: [DONE]`. We fold the chunk types that matter into an ordered `parts` array
|
|
7
|
+
* — the SAME ai-sdk `UIMessagePart[]` shape `@lotics/ui`'s `AgentRun` renders, so
|
|
8
|
+
* `<AgentRun parts={run.parts} />` needs no adapter. `ai` is a TYPE-ONLY import here
|
|
9
|
+
* (tool calls are hand-built as concrete `dynamic-tool` parts); NO `ai` runtime
|
|
10
|
+
* enters the sandboxed app bundle. Unknown chunk types are ignored, so a newer
|
|
11
|
+
* AI-SDK never breaks the SDK.
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* injected `submit_result` tool → `output`; a FREE-TEXT agent's result is its prose
|
|
17
|
-
* → the transcript's text. `output` is NEVER the free-text (see
|
|
18
|
-
* `AgentRunState.output`). Pure + reducer-shaped so it is fully unit-testable
|
|
19
|
-
* against recorded frames — no live model needed.
|
|
13
|
+
* An agent can be either kind: a STRUCTURED agent (declares `outputs`) emits its
|
|
14
|
+
* result as the input of the injected `submit_result` tool → `output` (NOT a part);
|
|
15
|
+
* a FREE-TEXT agent's result is its prose → a text part. `output` is NEVER the
|
|
16
|
+
* free-text (see `AgentRunState.output`). Pure + reducer-shaped so it is fully
|
|
17
|
+
* unit-testable against recorded frames — no live model needed.
|
|
20
18
|
*/
|
|
21
19
|
/** The tool the backend injects to carry a typed structured result. */
|
|
22
20
|
const SUBMIT_TOOL = "submit_result";
|
|
@@ -39,94 +37,89 @@ export function adoptSettledRun(state, settled) {
|
|
|
39
37
|
return { ...state, status: "error", error: settled.error_message ?? "The run was stopped." };
|
|
40
38
|
}
|
|
41
39
|
export function initialAgentRunState() {
|
|
42
|
-
return { status: "streaming",
|
|
40
|
+
return { status: "streaming", parts: [] };
|
|
43
41
|
}
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
return chars < 1024 ? `${chars} B` : `${Math.round(chars / 1024)} KB`;
|
|
47
|
-
}
|
|
48
|
-
/** Grow the trailing prose segment of the given kind, or open a new one (after a
|
|
49
|
-
* tool ran, or when the kind flips text↔reasoning) — so the transcript interleaves
|
|
42
|
+
/** Grow the trailing prose part of the given kind, or open a new one (after a tool
|
|
43
|
+
* ran, or when the kind flips text↔reasoning) — so the transcript interleaves
|
|
50
44
|
* answer prose, thinking, and tools in the order they streamed. */
|
|
51
|
-
function appendProse(
|
|
52
|
-
const last =
|
|
53
|
-
if (last
|
|
54
|
-
return [...
|
|
45
|
+
function appendProse(parts, kind, piece) {
|
|
46
|
+
const last = parts[parts.length - 1];
|
|
47
|
+
if (last && last.type === kind) {
|
|
48
|
+
return [...parts.slice(0, -1), { ...last, text: last.text + piece }];
|
|
55
49
|
}
|
|
56
|
-
|
|
50
|
+
const opened = kind === "text" ? { type: "text", text: piece, state: "streaming" } : { type: "reasoning", text: piece, state: "streaming" };
|
|
51
|
+
return [...parts, opened];
|
|
57
52
|
}
|
|
58
|
-
/** Apply an update to the tool
|
|
59
|
-
* the rest untouched. A missing id (older frame) or no match is a no-op. */
|
|
60
|
-
function
|
|
53
|
+
/** Apply an update to the `dynamic-tool` part with the given call id (by identity),
|
|
54
|
+
* leaving the rest untouched. A missing id (older frame) or no match is a no-op. */
|
|
55
|
+
function updateTool(parts, toolCallId, fn) {
|
|
61
56
|
if (!toolCallId)
|
|
62
|
-
return
|
|
63
|
-
return
|
|
57
|
+
return parts;
|
|
58
|
+
return parts.map((p) => (p.type === "dynamic-tool" && p.toolCallId === toolCallId ? fn(p) : p));
|
|
64
59
|
}
|
|
65
60
|
/** Fold one chunk into the run state. Returns a new state (immutable). */
|
|
66
61
|
export function reduceAgentChunk(state, chunk) {
|
|
67
62
|
switch (chunk.type) {
|
|
68
63
|
case "text-delta": {
|
|
69
64
|
const piece = chunk.delta ?? "";
|
|
70
|
-
return piece ? { ...state,
|
|
65
|
+
return piece ? { ...state, parts: appendProse(state.parts, "text", piece) } : state;
|
|
71
66
|
}
|
|
72
67
|
case "reasoning-delta": {
|
|
73
|
-
// Thinking is its OWN
|
|
68
|
+
// Thinking is its OWN part (not folded into the answer prose) so the UI can
|
|
74
69
|
// keep it collapsed / revealed-on-demand rather than inline in the answer.
|
|
75
70
|
const piece = chunk.delta ?? "";
|
|
76
|
-
return piece ? { ...state,
|
|
71
|
+
return piece ? { ...state, parts: appendProse(state.parts, "reasoning", piece) } : state;
|
|
77
72
|
}
|
|
78
73
|
case "tool-input-start":
|
|
79
74
|
case "tool-input-available": {
|
|
80
75
|
const name = chunk.toolName ?? "tool";
|
|
81
76
|
if (name === SUBMIT_TOOL) {
|
|
82
|
-
// The agent emitted its structured result — not a
|
|
77
|
+
// The agent emitted its structured result — routed to `output`, not a part.
|
|
83
78
|
return { ...state, output: chunk.input };
|
|
84
79
|
}
|
|
85
|
-
const id = chunk.toolCallId ?? `${name}-${state.
|
|
86
|
-
// Upsert by call id: the
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
const existing = state.
|
|
80
|
+
const id = chunk.toolCallId ?? `${name}-${state.parts.length}`;
|
|
81
|
+
// Upsert by call id: the tool part opens "input-available" (a running step in
|
|
82
|
+
// the feed) the moment the call fires and settles when its output arrives
|
|
83
|
+
// (below), so the feed shows a live tool, not an instantly-done one.
|
|
84
|
+
const existing = state.parts.some((p) => p.type === "dynamic-tool" && p.toolCallId === id);
|
|
90
85
|
if (existing) {
|
|
91
|
-
return { ...state,
|
|
86
|
+
return { ...state, parts: updateTool(state.parts, id, (p) => ({ type: "dynamic-tool", toolName: p.toolName || name, toolCallId: id, state: "input-available", input: chunk.input ?? p.input })) };
|
|
92
87
|
}
|
|
93
88
|
return {
|
|
94
89
|
...state,
|
|
95
|
-
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
case "tool-input-delta": {
|
|
99
|
-
// A tool's arguments streaming in — grow the step's live size so a long
|
|
100
|
-
// generation reads as progressing. `submit_result` created no step (it sets
|
|
101
|
-
// `output`), so its deltas find no matching step and this no-ops.
|
|
102
|
-
const len = (chunk.inputTextDelta ?? "").length;
|
|
103
|
-
if (!len)
|
|
104
|
-
return state;
|
|
105
|
-
return {
|
|
106
|
-
...state,
|
|
107
|
-
items: updateStep(state.items, chunk.toolCallId, (s) => {
|
|
108
|
-
const chars = (s.streamedChars ?? 0) + len;
|
|
109
|
-
return { ...s, streamedChars: chars, detail: formatStreamSize(chars) };
|
|
110
|
-
}),
|
|
90
|
+
parts: [...state.parts, { type: "dynamic-tool", toolName: name, toolCallId: id, state: "input-available", input: chunk.input }],
|
|
111
91
|
};
|
|
112
92
|
}
|
|
93
|
+
case "tool-input-delta":
|
|
94
|
+
// A tool's arguments streaming in. The ai-parts model carries no live size, so
|
|
95
|
+
// this is a no-op — the part already reads as running.
|
|
96
|
+
return state;
|
|
113
97
|
case "tool-output-available":
|
|
114
|
-
|
|
115
|
-
return { ...state, items: updateStep(state.items, chunk.toolCallId, (s) => ({ ...s, status: "done", output: chunk.output, detail: undefined, streamedChars: undefined })) };
|
|
98
|
+
return { ...state, parts: updateTool(state.parts, chunk.toolCallId, (p) => ({ type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-available", input: p.input, output: chunk.output })) };
|
|
116
99
|
case "tool-output-error":
|
|
117
|
-
return { ...state,
|
|
100
|
+
return { ...state, parts: updateTool(state.parts, chunk.toolCallId, (p) => ({ type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-error", input: p.input, errorText: chunk.errorText ?? "The tool failed." })) };
|
|
118
101
|
case "error":
|
|
119
102
|
return { ...state, status: "error", error: chunk.errorText ?? "The run failed." };
|
|
120
103
|
case "abort":
|
|
121
104
|
return { ...state, status: state.status === "streaming" ? "error" : state.status, error: state.error ?? "The run was stopped." };
|
|
122
105
|
case "finish": {
|
|
123
|
-
// Settle the status, and settle
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
106
|
+
// Settle the status, and settle anything still streaming so nothing reads as
|
|
107
|
+
// in-progress on a completed run: a tool with no output frame → output-available
|
|
108
|
+
// (its output never came); a text/reasoning part still "streaming" → "done" (the
|
|
109
|
+
// stream's `text-end`/`reasoning-end` markers aren't tracked per-part). `output`
|
|
110
|
+
// is whatever `submit_result` set (else undefined) — NEVER the accumulated text:
|
|
111
|
+
// a structured-output consumer reads `output.<field>`, so a stray free-text
|
|
112
|
+
// string there would crash it. The free-text answer is always in a text part.
|
|
113
|
+
const parts = state.parts.map((p) => {
|
|
114
|
+
if (p.type === "dynamic-tool" && (p.state === "input-available" || p.state === "input-streaming")) {
|
|
115
|
+
return { type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-available", input: p.input, output: undefined };
|
|
116
|
+
}
|
|
117
|
+
if ((p.type === "text" || p.type === "reasoning") && p.state === "streaming") {
|
|
118
|
+
return { ...p, state: "done" };
|
|
119
|
+
}
|
|
120
|
+
return p;
|
|
121
|
+
});
|
|
122
|
+
return { ...state, status: state.status === "error" ? "error" : "completed", parts };
|
|
130
123
|
}
|
|
131
124
|
default:
|
|
132
125
|
return state;
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { type AiContextValue } from "./rpc.js";
|
|
2
|
-
import { type
|
|
2
|
+
import { type AgentUIPart } from "./agent_stream.js";
|
|
3
3
|
import type { AppWorkflows, AppWorkflowResults, AppQueries, AppAgents, AppAgentResults } from "./types.js";
|
|
4
4
|
import type { ResolvedMember } from "./members.js";
|
|
5
5
|
import type { ResolvedOption } from "./select.js";
|
|
6
|
-
export type {
|
|
6
|
+
export type { AgentRunState, AgentUIPart } from "./agent_stream.js";
|
|
7
7
|
/** Fields shared by every query hook's return value. */
|
|
8
8
|
interface QueryStateBase {
|
|
9
9
|
/**
|
|
@@ -470,20 +470,15 @@ export interface UseAgentRun<TInput, TOutput> {
|
|
|
470
470
|
* user-facing "Stop" button to this, not `abort`. */
|
|
471
471
|
cancel: () => void;
|
|
472
472
|
status: "idle" | "streaming" | "completed" | "error";
|
|
473
|
-
/** The ordered run transcript — prose interleaved with tool
|
|
474
|
-
* order. Feed straight to `@lotics/ui` `AgentRun`:
|
|
475
|
-
* `<AgentRun
|
|
473
|
+
/** The ordered run transcript as ai-sdk `parts` — prose interleaved with tool
|
|
474
|
+
* calls, in stream order. Feed straight to `@lotics/ui` `AgentRun`:
|
|
475
|
+
* `<AgentRun parts={run.parts} state={run.status === "streaming" ? "streaming" : run.status === "error" ? "error" : "done"} />`
|
|
476
476
|
* — no hand-assembly. */
|
|
477
|
-
|
|
478
|
-
/** The agent's ANSWER prose (every `text`
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
* Derived from `items`. */
|
|
477
|
+
parts: AgentUIPart[];
|
|
478
|
+
/** The agent's ANSWER prose (every `text` part concatenated), accumulating live —
|
|
479
|
+
* excludes thinking (`reasoning` is its own part). For a FREE-TEXT agent this IS
|
|
480
|
+
* the result; a structured agent's result is `output`. Derived from `parts`. */
|
|
482
481
|
text: string;
|
|
483
|
-
/** Only the tool/step items (each with its `input`/`output`/`status`) — backward
|
|
484
|
-
* compat with the older steps-only `AgentRun`. Prefer `items` (it keeps the
|
|
485
|
-
* text↔reasoning↔tool ordering the feed renders). Derived from `items`. */
|
|
486
|
-
steps: AgentRunStep[];
|
|
487
482
|
/** The structured result once the run completes — the agent's `submit_result`
|
|
488
483
|
* output. `undefined` when the run produced none (a free-text agent, or one that
|
|
489
484
|
* finished without submitting); a free-text answer lives in `text`, never here.
|
|
@@ -496,14 +491,14 @@ export interface UseAgentRun<TInput, TOutput> {
|
|
|
496
491
|
/**
|
|
497
492
|
* Run a streaming agent declared in `package.json` lotics.agents and invoked by
|
|
498
493
|
* alias. `run(input, { sessionId })` starts it; progress streams into `status` +
|
|
499
|
-
* the ordered `
|
|
494
|
+
* the ordered `parts` transcript (feed straight to `@lotics/ui` `AgentRun`). A
|
|
500
495
|
* STRUCTURED agent's result lands in `output`; a FREE-TEXT agent's answer is the
|
|
501
496
|
* transcript's prose (`text`). Read the session's history with `useAgentRuns`.
|
|
502
497
|
*
|
|
503
498
|
* ```tsx
|
|
504
499
|
* const recognize = useAgentRun("recognize");
|
|
505
500
|
* await recognize.run({ image_file_id }, { sessionId });
|
|
506
|
-
* // <AgentRun
|
|
501
|
+
* // <AgentRun parts={recognize.parts} state={recognize.status === "streaming" ? "streaming" : "done"} />
|
|
507
502
|
* // then read recognize.output (structured) or recognize.text (free-text)
|
|
508
503
|
* ```
|
|
509
504
|
*/
|
package/dist/src/hooks.js
CHANGED
|
@@ -586,26 +586,24 @@ export function useAgentRun(alias) {
|
|
|
586
586
|
void rpc("agentRun.cancel", { run_id: runId }).catch(() => { });
|
|
587
587
|
handleRef.current?.abort();
|
|
588
588
|
}, []);
|
|
589
|
-
// `
|
|
590
|
-
//
|
|
591
|
-
const
|
|
592
|
-
const text = useMemo(() =>
|
|
593
|
-
const steps = useMemo(() => items.flatMap((i) => (i.type === "step" ? [{ id: i.id, label: i.label, detail: i.detail, status: i.status, kind: i.kind, input: i.input, output: i.output, errorText: i.errorText }] : [])), [items]);
|
|
589
|
+
// `parts` is the source of truth; `text` (all prose concatenated) is the derived
|
|
590
|
+
// answer view.
|
|
591
|
+
const parts = state?.parts ?? EMPTY_PARTS;
|
|
592
|
+
const text = useMemo(() => parts.reduce((acc, p) => (p.type === "text" ? acc + p.text : acc), ""), [parts]);
|
|
594
593
|
return {
|
|
595
594
|
run,
|
|
596
595
|
abort,
|
|
597
596
|
cancel,
|
|
598
597
|
status: state?.status ?? "idle",
|
|
599
|
-
|
|
598
|
+
parts,
|
|
600
599
|
text,
|
|
601
|
-
steps,
|
|
602
600
|
output: state?.output,
|
|
603
601
|
error: state?.error,
|
|
604
602
|
};
|
|
605
603
|
}
|
|
606
|
-
/** Stable empty transcript so an idle hook returns a constant `
|
|
604
|
+
/** Stable empty transcript so an idle hook returns a constant `parts` reference
|
|
607
605
|
* (no new [] each render → dependents don't re-run needlessly). */
|
|
608
|
-
const
|
|
606
|
+
const EMPTY_PARTS = [];
|
|
609
607
|
/**
|
|
610
608
|
* Bounded PAST the server-side max-run cap (20 min) plus settle grace. The
|
|
611
609
|
* server guarantees a LIVE run settles by its own cap timer, so a row still
|
package/dist/src/index.d.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
export { mount } from "./mount.js";
|
|
18
18
|
export type { MountOptions } from "./mount.js";
|
|
19
19
|
export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, } from "./hooks.js";
|
|
20
|
-
export type { UploadedFile, AttachedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunRecord, AgentRunState,
|
|
20
|
+
export type { UploadedFile, AttachedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunRecord, AgentRunState, AgentUIPart, 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";
|
package/dist/src/row.d.ts
CHANGED
|
@@ -24,7 +24,9 @@ declare function bool(v: unknown): boolean;
|
|
|
24
24
|
/**
|
|
25
25
|
* Date/datetime field → a LOCAL-midnight Date for the stored calendar day, so
|
|
26
26
|
* calendar/gantt placement never shifts across timezones. Parses the leading
|
|
27
|
-
* `YYYY-MM-DD` of the serialized string
|
|
27
|
+
* `YYYY[-MM[-DD]]` of the serialized string — a reduced-precision `date` value
|
|
28
|
+
* ("2026-05" / "2026") decodes to its PERIOD START (missing month/day → 1)
|
|
29
|
+
* rather than vanishing to null. Null only if absent or unparseable. (Range
|
|
28
30
|
* fields are not handled here — they have no consumer yet.)
|
|
29
31
|
*/
|
|
30
32
|
declare function date(v: unknown): Date | null;
|
|
@@ -34,8 +36,10 @@ declare function date(v: unknown): Date | null;
|
|
|
34
36
|
* value is a timezone-less workspace wall-clock (see the serialization note
|
|
35
37
|
* above), so it is read verbatim — no UTC conversion. Use this when the time
|
|
36
38
|
* matters (a check-in time, an appointment); `date` keeps only the calendar day.
|
|
37
|
-
* Parses `YYYY-MM-DD` with an optional `T`-or-space `HH:mm[:ss]`; a missing
|
|
38
|
-
* is
|
|
39
|
+
* Parses `YYYY[-MM[-DD]]` with an optional `T`-or-space `HH:mm[:ss]`; a missing
|
|
40
|
+
* month/day is the period start (1) and a missing time is midnight — a
|
|
41
|
+
* reduced-precision value decodes rather than vanishing. Null if absent or
|
|
42
|
+
* unparseable.
|
|
39
43
|
*/
|
|
40
44
|
declare function datetime(v: unknown): Date | null;
|
|
41
45
|
/** A linked record cell entry — the target record's id + its display text. */
|
package/dist/src/row.js
CHANGED
|
@@ -53,12 +53,16 @@ function bool(v) {
|
|
|
53
53
|
/**
|
|
54
54
|
* Date/datetime field → a LOCAL-midnight Date for the stored calendar day, so
|
|
55
55
|
* calendar/gantt placement never shifts across timezones. Parses the leading
|
|
56
|
-
* `YYYY-MM-DD` of the serialized string
|
|
56
|
+
* `YYYY[-MM[-DD]]` of the serialized string — a reduced-precision `date` value
|
|
57
|
+
* ("2026-05" / "2026") decodes to its PERIOD START (missing month/day → 1)
|
|
58
|
+
* rather than vanishing to null. Null only if absent or unparseable. (Range
|
|
57
59
|
* fields are not handled here — they have no consumer yet.)
|
|
58
60
|
*/
|
|
59
61
|
function date(v) {
|
|
60
|
-
const m = /^(\d{4})
|
|
61
|
-
|
|
62
|
+
const m = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?/.exec(text(v));
|
|
63
|
+
if (!m)
|
|
64
|
+
return null;
|
|
65
|
+
return new Date(Number(m[1]), (m[2] ? Number(m[2]) : 1) - 1, m[3] ? Number(m[3]) : 1);
|
|
62
66
|
}
|
|
63
67
|
/**
|
|
64
68
|
* Date/datetime field → a LOCAL Date that KEEPS the stored wall-clock time, so
|
|
@@ -66,14 +70,16 @@ function date(v) {
|
|
|
66
70
|
* value is a timezone-less workspace wall-clock (see the serialization note
|
|
67
71
|
* above), so it is read verbatim — no UTC conversion. Use this when the time
|
|
68
72
|
* matters (a check-in time, an appointment); `date` keeps only the calendar day.
|
|
69
|
-
* Parses `YYYY-MM-DD` with an optional `T`-or-space `HH:mm[:ss]`; a missing
|
|
70
|
-
* is
|
|
73
|
+
* Parses `YYYY[-MM[-DD]]` with an optional `T`-or-space `HH:mm[:ss]`; a missing
|
|
74
|
+
* month/day is the period start (1) and a missing time is midnight — a
|
|
75
|
+
* reduced-precision value decodes rather than vanishing. Null if absent or
|
|
76
|
+
* unparseable.
|
|
71
77
|
*/
|
|
72
78
|
function datetime(v) {
|
|
73
|
-
const m = /^(\d{4})
|
|
79
|
+
const m = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?(?:[ T](\d{2}):(\d{2}))?/.exec(text(v));
|
|
74
80
|
if (!m)
|
|
75
81
|
return null;
|
|
76
|
-
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), m[4] ? Number(m[4]) : 0, m[5] ? Number(m[5]) : 0);
|
|
82
|
+
return new Date(Number(m[1]), (m[2] ? Number(m[2]) : 1) - 1, m[3] ? Number(m[3]) : 1, m[4] ? Number(m[4]) : 0, m[5] ? Number(m[5]) : 0);
|
|
77
83
|
}
|
|
78
84
|
/** One `{ id, display }` object → ResolvedLink, or null if absent/malformed. */
|
|
79
85
|
function asLink(v) {
|
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. 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` (`
|
|
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` (`AgentRunState`, `AgentUIPart`), `dist/src/ask_ai.d.ts` (`AskAiArgs`).
|
|
4
4
|
|
|
5
5
|
## Choosing the surface — the fields-vs-file razor
|
|
6
6
|
|
|
@@ -41,7 +41,7 @@ import { useAgentRun } from "@lotics/app-sdk";
|
|
|
41
41
|
|
|
42
42
|
const recognize = useAgentRun("recognize");
|
|
43
43
|
await recognize.run({ image_file_id: fileId }, { sessionId });
|
|
44
|
-
// live: recognize.status, recognize.
|
|
44
|
+
// live: recognize.status, recognize.parts
|
|
45
45
|
// done: recognize.output (structured) or recognize.text (free-text)
|
|
46
46
|
```
|
|
47
47
|
|
|
@@ -53,33 +53,33 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
|
|
|
53
53
|
| `cancel` | `() => void` | Stop the run **server-side** (saves tokens) and locally. Wire a user-facing Stop button to this |
|
|
54
54
|
| `abort` | `() => void` | Stop listening **locally only** — the run keeps executing server-side and its result is still persisted. This is the unmount path (the hook calls it automatically on unmount) |
|
|
55
55
|
| `status` | `"idle" \| "streaming" \| "completed" \| "error"` | Whole-run state. `abort`/`cancel` reset it to `"idle"` (and clear the partial transcript) |
|
|
56
|
-
| `
|
|
57
|
-
| `text` | `string` | The agent's **answer prose** (every `text`
|
|
58
|
-
| `steps` | `AgentRunStep[]` | Only the tool/step items — a backward-compat view derived from `items`. Prefer `items` (it keeps the text↔reasoning↔tool ordering) |
|
|
56
|
+
| `parts` | `AgentUIPart[]` | The ordered live transcript as **ai-sdk `UIMessage.parts`** — answer prose, thinking, and tool calls, in stream order. The single source of truth for the feed; hand it straight to `@lotics/ui` `AgentRun` |
|
|
57
|
+
| `text` | `string` | The agent's **answer prose** (every `text` part concatenated), accumulating live. Excludes thinking. For a free-text agent this IS the result |
|
|
59
58
|
| `output` | `TOutput \| undefined` | The structured result once the run completes. `undefined` when the run produced none |
|
|
60
59
|
| `error` | `string \| undefined` | The failure message when `status` is `"error"` |
|
|
61
60
|
|
|
62
|
-
### The `
|
|
61
|
+
### The `parts` transcript → `@lotics/ui` `AgentRun`
|
|
63
62
|
|
|
64
|
-
`
|
|
63
|
+
`parts` is the **ai-sdk `UIMessagePart[]`** shape — the same wire model chat and app agents both emit, so there is no bespoke transcript type to keep in sync. The reducer folds the run's SSE chunks into it, in stream order:
|
|
65
64
|
|
|
66
|
-
| `type` | Carries | Rendering intent |
|
|
65
|
+
| Part `type` | Carries | Rendering intent |
|
|
67
66
|
|---|---|---|
|
|
68
|
-
| `"text"` | `
|
|
69
|
-
| `"reasoning"` | `
|
|
70
|
-
| `"
|
|
67
|
+
| `"text"` | `text`, `state` | Answer prose, grows as it streams |
|
|
68
|
+
| `"reasoning"` | `text`, `state` | The agent's thinking — a **distinct part**, kept out of `text`, so `AgentRun` shows it collapsed / revealed on demand. Only produced when the agent's declared model supports thinking (**Sonnet 4.6 / Opus 4.8**, not Haiku) — a Haiku agent never emits reasoning |
|
|
69
|
+
| `"dynamic-tool"` | `toolName`, `toolCallId`, `state` (ai's tool lifecycle — `input-available` → `output-available` / `output-error`), `input`, `output`, `errorText` | One tool call — opens running the moment it fires, settles when its result arrives. `input`/`output` ride along for `AgentRun`'s on-demand reveal, not for the feed row |
|
|
71
70
|
|
|
72
|
-
The
|
|
71
|
+
The terminal `submit_result` call is captured into `output`, **not** rendered as a tool part (structured agents therefore have no visible final step). Source/file parts aren't emitted by app agents. `parts` is exactly what `@lotics/ui` `AgentRun` renders, so the pairing needs **no adapter and no hand-assembly**:
|
|
73
72
|
|
|
74
73
|
```tsx
|
|
75
74
|
<AgentRun
|
|
76
|
-
|
|
75
|
+
parts={run.parts}
|
|
77
76
|
state={run.status === "streaming" ? "streaming" : run.status === "error" ? "error" : "done"}
|
|
77
|
+
error={run.error} // the breaking error — a terminal danger row
|
|
78
78
|
labelForTool={(name) => toolLabels[name]} // optional: localize tool labels
|
|
79
79
|
/>
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
`AgentRun` renders thinking collapsed, groups consecutive tool
|
|
82
|
+
`AgentRun` renders thinking collapsed, groups consecutive tool calls, and expands each tool's input/output in place on press — all for free. **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.
|
|
83
83
|
|
|
84
84
|
### `output` typing and the inner-field caveat
|
|
85
85
|
|
|
@@ -130,7 +130,7 @@ Both truncation shapes emit the `app_agent_stream_truncated` analytics event (`k
|
|
|
130
130
|
|
|
131
131
|
The poll is bounded at 22 minutes — deliberately PAST the server's 20-minute hard run cap, so a live run always settles before the client gives up. A row still `running` at the deadline means the run's process died mid-flight (e.g. a crash that skipped the server's shutdown drain); the poll surfaces an error and the server's reaper repairs the row. Only when no run id was ever received (the run never started) does the failure reject before any polling.
|
|
132
132
|
|
|
133
|
-
On recovery, `output` adopts the row's output **only when it is an object** (a structured result) — the "`output` is never a stray string" rule holds on every path. A **free-text** run recovered from truncation keeps only the streamed prefix in `text`;
|
|
133
|
+
On recovery, `output` adopts the row's output **only when it is an object** (a structured result) — the "`output` is never a stray string" rule holds on every path. A **free-text** run recovered from truncation keeps only the streamed prefix in `text`; the full settled answer is persisted server-side but is not currently client-readable (`useAgentRuns` can't reach it on any transport — see the limitation below), so treat the streamed prefix as terminal for now.
|
|
134
134
|
|
|
135
135
|
### Sessions
|
|
136
136
|
|
|
@@ -266,7 +266,6 @@ The floors below are when each capability shipped in `@lotics/app-sdk`; an app p
|
|
|
266
266
|
|---|---|
|
|
267
267
|
| `useAgentRun` (with `abort`) + `useAgentRuns` | `@lotics/app-sdk` 0.34 |
|
|
268
268
|
| `cancel()` (server-side stop) + connection-drop recovery | `@lotics/app-sdk` 0.37 |
|
|
269
|
-
| `
|
|
270
|
-
| Live streamed-argument size on a running step (`detail`) | `@lotics/app-sdk` 0.44 |
|
|
269
|
+
| `parts` transcript (ai-sdk `UIMessagePart[]` — reasoning + per-tool `input`/`output`) | `@lotics/app-sdk` 0.54; renders with `@lotics/ui` ≥ 14.0 (`AgentRun` `parts` prop) |
|
|
271
270
|
| `askAi` | `@lotics/app-sdk` 0.45 |
|
|
272
271
|
| `useAiContext` (ambient-chat view state + auto query refetch on chat mutation) | `@lotics/app-sdk` 0.52 |
|
package/docs/data_fetching.md
CHANGED
|
@@ -228,7 +228,7 @@ Wire shapes per output column type:
|
|
|
228
228
|
| `text` | `string` |
|
|
229
229
|
| `number` | `number` (numeric database strings are normalized server-side; a rare decimal too precise for float64 stays a string — `row.num` parses both) |
|
|
230
230
|
| `boolean` | `boolean` |
|
|
231
|
-
| `date` | `"YYYY-MM-DD"` — a calendar day, no time component |
|
|
231
|
+
| `date` | `"YYYY-MM-DD"` — a calendar day, no time component (a reduced-precision date field may also carry `"YYYY-MM"` or `"YYYY"`; see below) |
|
|
232
232
|
| `datetime` | `"YYYY-MM-DDTHH:mm"` — workspace wall-clock, minute precision |
|
|
233
233
|
| `select` | `Array<{ key, label }>` — one entry per selected option |
|
|
234
234
|
| `select_member` | `Array<{ id, name, email? }>` — `email` only for authenticated viewers of the app's own org |
|
|
@@ -249,8 +249,8 @@ metadata. A grouped query collapses rows and emits none of these. Details:
|
|
|
249
249
|
| `row.text(cell)` | any → `string` | strings pass through, finite numbers stringify, everything else → `""` |
|
|
250
250
|
| `row.num(cell)` | any → `number` | numbers pass (NaN/Infinity → 0), parseable strings parse, everything else → `0` |
|
|
251
251
|
| `row.bool(cell)` | any → `boolean` | `true` or the string `"true"`; everything else `false` |
|
|
252
|
-
| `row.date(cell)` | date/datetime cell → `Date \| null` | the stored **calendar day** at LOCAL midnight — time stripped |
|
|
253
|
-
| `row.datetime(cell)` | date/datetime cell → `Date \| null` | local `Date` **keeping the stored wall-clock** (minute precision; seconds are dropped); missing time = midnight |
|
|
252
|
+
| `row.date(cell)` | date/datetime cell → `Date \| null` | the stored **calendar day** at LOCAL midnight — time stripped; a reduced-precision value decodes to its **period start** |
|
|
253
|
+
| `row.datetime(cell)` | date/datetime cell → `Date \| null` | local `Date` **keeping the stored wall-clock** (minute precision; seconds are dropped); missing time = midnight; a reduced-precision value decodes to its **period start** |
|
|
254
254
|
| `row.link(cell)` | link cell → `{ id, display } \| null` | the FIRST linked record |
|
|
255
255
|
| `readLinks(cell)` | link cell → `{ id, display }[]` | ALL linked records (`[]` when empty) |
|
|
256
256
|
| `readSelect(cell)` | select cell → `ResolvedOption[]` | all selected options as `{ key, label }` (`[]` when empty) |
|
|
@@ -262,7 +262,7 @@ All readers are pure (`unknown` in, value out), never throw, and return their em
|
|
|
262
262
|
(`null` / `""` / `0` / `false` / `[]`) for absent or malformed input — so callers iterate and
|
|
263
263
|
render without null-check pyramids.
|
|
264
264
|
|
|
265
|
-
**`row.date` vs `row.datetime`.** `row.date` parses
|
|
265
|
+
**`row.date` vs `row.datetime`.** `row.date` parses the leading `YYYY[-MM[-DD]]` and builds a
|
|
266
266
|
LOCAL-midnight `Date` — calendar/gantt placement never shifts across viewer timezones. `row.datetime`
|
|
267
267
|
keeps the stored wall-clock verbatim (no UTC conversion — the stored value is a timezone-less
|
|
268
268
|
workspace wall-clock), so `getHours()` / `toLocaleTimeString()` render the time as written. A
|
|
@@ -270,6 +270,14 @@ workspace wall-clock), so `getHours()` / `toLocaleTimeString()` render the time
|
|
|
270
270
|
and the UI prints `00:00`. When you need the time, the query must output the column as `datetime`
|
|
271
271
|
(the type-override rules are in [./queries.md](./queries.md)).
|
|
272
272
|
|
|
273
|
+
**Reduced-precision date values.** A `date` field may store an ISO 8601 truncated value —
|
|
274
|
+
`"2026-05"` (month) or `"2026"` (year) — when a document carries only that precision. Both decoders
|
|
275
|
+
resolve it to its **period start** (missing month/day → 1): `row.date("2026-05")` →
|
|
276
|
+
May 1 2026 at local midnight, `row.date("2026")` → Jan 1 2026 — never `null`. For DISPLAY that
|
|
277
|
+
respects the precision (show "05/2026", not "01/05/2026"), format with `@lotics/ui`'s `formatDate`
|
|
278
|
+
on the raw cell string rather than the decoded `Date`. Sorting/filtering server-side already treats
|
|
279
|
+
a partial as its period start.
|
|
280
|
+
|
|
273
281
|
**`readSelect`.** A query **cell** carries `key` + `label` only — `color` comes from
|
|
274
282
|
`useFieldOptions`, not the cell. An option deleted after the cell was written surfaces as
|
|
275
283
|
`label === key` (the stale state is explicit, never hidden). Render with `@lotics/ui` `OptionBadge`
|
package/docs/mutations.md
CHANGED
|
@@ -128,12 +128,16 @@ A workflow body ends with `return({ status, message?, data? })`. `data` is arbit
|
|
|
128
128
|
structured data (computed totals, row lists, status objects) the app reads back as
|
|
129
129
|
`result.data`:
|
|
130
130
|
|
|
131
|
-
- **Typed for free
|
|
132
|
-
TypeScript type of the body's `return({ data })` — the return *is*
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
131
|
+
- **Typed for free — persisted at `set`.** The alias's `outputs` schema is derived at save
|
|
132
|
+
time from the inferred TypeScript type of the body's `return({ data })` — the return *is*
|
|
133
|
+
the declaration. When the manifest declared **no** `outputs`, `lotics app workflow set`
|
|
134
|
+
writes the server-derived schema into `package.json#lotics.workflows.<alias>.outputs` and
|
|
135
|
+
refreshes that alias's generated types in the same command, so `result.data` is typed
|
|
136
|
+
immediately — no hand-copy of the echoed schema, no second `lotics app codegen`. An
|
|
137
|
+
**explicitly declared** `outputs` is authoritative and never overwritten. Declare an explicit
|
|
138
|
+
`outputs` (same recursive vocabulary as inputs: scalars plus nested `object`/`array`, no
|
|
139
|
+
member/file/date_range) only to narrow beyond what's inferred; a shape the checker can't pin
|
|
140
|
+
down degrades to untyped `json`, never to a wrong schema.
|
|
137
141
|
- **Validated at run.** On a success return, the returned `data` is validated against the
|
|
138
142
|
schema at the app boundary — a mismatch resolves as `status: "error"` with a field-level
|
|
139
143
|
message, so a declared output is a real contract. An *error* return's `data` passes through
|
|
@@ -173,7 +177,7 @@ Every declaration takes optional `description` and `required` (default **true**)
|
|
|
173
177
|
| `date` | — | `"YYYY-MM-DD"`, or a TZ-bearing ISO datetime (projected to the workspace day) | `string` |
|
|
174
178
|
| `datetime` | — | `"YYYY-MM-DDTHH:mm"` naive workspace wall-clock (what the platform DatePicker emits), or TZ-bearing ISO | `string` |
|
|
175
179
|
| `email` | — | a valid email | `string` |
|
|
176
|
-
| `select` | `options: [{label, value}]` (min 1)
|
|
180
|
+
| `select` | exactly one of `options: [{label, value}]` (min 1) **or** `field: "fld_…"`; `multi?` | an option key (array when `multi`) | union of the option `value`s (inline) or the field's current option keys (`field`); `ReadonlyArray<…>` when `multi` |
|
|
177
181
|
| `record_link` | `table_id` (required), `multi?` | a record id — must exist **in the declared table** | `string` / `ReadonlyArray<string>` |
|
|
178
182
|
| `member` | `group?`, `multi?` | a member id — with `group`, must belong to that group | `string` / `ReadonlyArray<string>` |
|
|
179
183
|
| `file` | `multi?` | a file id from an upload — must live in the app's workspace | `string` / `ReadonlyArray<string>` |
|
|
@@ -209,12 +213,24 @@ When the alias declares `inputs`, the server validates the payload before the wo
|
|
|
209
213
|
declared group; every `file` id must live in the app's workspace. These are real write-time
|
|
210
214
|
constraints, not picker cosmetics — a hand-crafted request can't redirect the workflow.
|
|
211
215
|
Rationale and the full caller-boundary model: [security](./security.md).
|
|
212
|
-
- **`select`
|
|
213
|
-
`options
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
- **`select` names its option set one of two ways — declare exactly one (both or neither is a
|
|
217
|
+
set-time error).** *Inline* `options: [{label, value}]` freezes the set into the codegen
|
|
218
|
+
literal union and is **format-only at run time**: any well-formed key is accepted, and an
|
|
219
|
+
option added to the live field after deploy is valid at run time but fails the compile-time
|
|
220
|
+
type (widen or redeploy the declaration when the frozen union gets in the way).
|
|
221
|
+
*Field-referenced* `field: "fld_…"` (a select field's global key — `fld_…`, no table prefix)
|
|
222
|
+
is **drift-proof**: the union is resolved from the field's *current* options at type-gen, and
|
|
223
|
+
the submitted value is validated against the field's *current* options at run time — an option
|
|
224
|
+
added after authoring is accepted, a removed one rejected
|
|
225
|
+
(`select input "<path>" value "<v>" is not one of field "<key>"'s current options`) with no
|
|
226
|
+
redeploy. A `field` that doesn't exist or names a non-select field is rejected at bind time
|
|
227
|
+
(`lotics app workflow set` / `set_app_workflow`; `lotics app deploy` never binds workflows) with
|
|
228
|
+
`select input references field "<key>", which does not exist in this workspace` /
|
|
229
|
+
`… which is a <type> field, not a select`. Prefer `field` for any select backed by a real
|
|
230
|
+
field; keep `options` for a fixed enum the app owns or a select you plan to **package** — a
|
|
231
|
+
`field`-form select can't ride a package contract (it carries a concrete field id), so inline
|
|
232
|
+
the options before publishing. Populate pickers from `useFieldOptions` either way (the live
|
|
233
|
+
option set — see [members_and_options](./members_and_options.md)).
|
|
218
234
|
|
|
219
235
|
If the alias declares **no** `inputs`, the payload passes through opaquely — no validation,
|
|
220
236
|
no typing, no reference binding. Fine for a zero-input action; declare inputs for anything
|
|
@@ -254,6 +270,27 @@ if (i.title != null) {
|
|
|
254
270
|
}
|
|
255
271
|
```
|
|
256
272
|
|
|
273
|
+
When several optional inputs each map to a field, per-field guards become noise. Hand the whole
|
|
274
|
+
bag to `update_records`' **`set_skip_null`** instead — same object shape as `set`, but entries
|
|
275
|
+
whose value is `null`/`undefined` are dropped, so only the fields actually provided get written
|
|
276
|
+
(untouched fields keep their lock / `before_update` / concurrent-edit safety — the diff-write
|
|
277
|
+
discipline below, done in the body):
|
|
278
|
+
|
|
279
|
+
```js
|
|
280
|
+
const i = trigger.app_workflow.inputs;
|
|
281
|
+
await update_records({
|
|
282
|
+
table_id: "tbl_items",
|
|
283
|
+
record_ids: [i.record_id],
|
|
284
|
+
set_skip_null: { fld_title: i.title, fld_status: i.status, fld_due: i.due }, // absent inputs drop out
|
|
285
|
+
});
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`set` still writes every key it carries — `null` in `set` **clears** the field; `null`/absent in
|
|
289
|
+
`set_skip_null` **skips** it. That is the per-field "clear vs. leave unchanged" choice, so a field
|
|
290
|
+
may appear in **at most one** of `set` / `set_skip_null` (naming it in both is rejected at run
|
|
291
|
+
time). An all-absent `set_skip_null` with no other write surface is a no-op — no records touched,
|
|
292
|
+
no `before_update` hooks.
|
|
293
|
+
|
|
257
294
|
## Refetch after a mutation
|
|
258
295
|
|
|
259
296
|
Query hooks cache through SWR and know nothing about your workflows — a successful mutation
|
|
@@ -281,9 +318,9 @@ in place of an explicit refetch after a write the user is watching for.
|
|
|
281
318
|
|
|
282
319
|
An edit form snapshots the record's values when it loads, and on save sends **only the fields
|
|
283
320
|
the user actually changed** to its update workflow. Declare each updatable input
|
|
284
|
-
`required: false`; an omitted input means "not written" (the body guards each write
|
|
285
|
-
above). "Changed" is decided at the edit surface that loaded
|
|
286
|
-
against the load-time snapshot, not against a re-fetch.
|
|
321
|
+
`required: false`; an omitted input means "not written" (the body guards each write — or hands
|
|
322
|
+
them to `set_skip_null` — as shown above). "Changed" is decided at the edit surface that loaded
|
|
323
|
+
the before-state — compare against the load-time snapshot, not against a re-fetch.
|
|
287
324
|
|
|
288
325
|
Why a full-form snapshot save is a bug, not a style choice — three independent mechanisms:
|
|
289
326
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.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": {
|
|
@@ -30,11 +30,15 @@
|
|
|
30
30
|
"swr": "^2.4.1"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
|
+
"ai": ">=7.0.0",
|
|
33
34
|
"react": "^19.2.0",
|
|
34
35
|
"react-dom": "^19.2.0",
|
|
35
36
|
"react-router-dom": "^7.0.0"
|
|
36
37
|
},
|
|
37
38
|
"peerDependenciesMeta": {
|
|
39
|
+
"ai": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
38
42
|
"react-router-dom": {
|
|
39
43
|
"optional": true
|
|
40
44
|
}
|