@lotics/app-sdk 0.53.0 → 0.54.1
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/docs/ai.md +15 -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/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 / Opus**, 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. Running it in a bounded container (a dialog, a panel)? Wrap it in `@lotics/ui`'s `FollowScroll` so the container follows the stream instead of letting new content grow below the fold. **Errors surface two ways:** a per-tool failure is an `output-error` part (amber dot, reason in the expanded Error panel — the feed flows on, exactly like a run that retried and recovered); a **breaking** error that killed the run lives in `run.error` (not in `parts`) — pass it as `error` and it renders as a terminal danger row. Never rebuild this feed by hand.
|
|
83
83
|
|
|
84
84
|
### `output` typing and the inner-field caveat
|
|
85
85
|
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.1",
|
|
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
|
}
|