@cubos/agent-sdk-react 0.0.1136563
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/README.md +304 -0
- package/dist/blocks.d.ts +47 -0
- package/dist/context.d.ts +23 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +876 -0
- package/dist/index.js.map +15 -0
- package/dist/use-conversation-events.d.ts +47 -0
- package/dist/use-conversation-list.d.ts +32 -0
- package/dist/use-conversation.d.ts +358 -0
- package/dist/use-identity.d.ts +9 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# @cubos/agent-sdk-react
|
|
2
|
+
|
|
3
|
+
Headless React hooks for the Cubos Agent conversation API.
|
|
4
|
+
|
|
5
|
+
**No DOM.** The package renders nothing and imports no `react-dom`, so the same
|
|
6
|
+
hooks drive a web app, React Native, or anything else React runs on. A test in
|
|
7
|
+
this package fails the build if a browser global or a `<div>` sneaks in.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @cubos/agent-sdk @cubos/agent-sdk-react
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## The whole thing
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { AgentProvider, useConversation } from "@cubos/agent-sdk-react";
|
|
17
|
+
|
|
18
|
+
function App() {
|
|
19
|
+
return (
|
|
20
|
+
<AgentProvider
|
|
21
|
+
baseUrl="https://agent.acme.com"
|
|
22
|
+
getToken={async () => (await fetch("/api/agent-token").then((r) => r.json())).token}
|
|
23
|
+
>
|
|
24
|
+
<Chat />
|
|
25
|
+
</AgentProvider>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function Chat() {
|
|
30
|
+
const [id, setId] = useState<string | null>(null);
|
|
31
|
+
const { messages, send, activity } = useConversation(id, {
|
|
32
|
+
agentSlug: "support",
|
|
33
|
+
onCreated: (conversation) => setId(conversation.id),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<>
|
|
38
|
+
{messages.map((m) => (
|
|
39
|
+
<div key={m.id} data-role={m.role}>
|
|
40
|
+
{m.content}
|
|
41
|
+
</div>
|
|
42
|
+
))}
|
|
43
|
+
{(activity.isProcessing || activity.hasPendingTurn) && <Typing />}
|
|
44
|
+
<Composer onSubmit={send} />
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`getToken` must return a **short-lived end-user token**, minted on your server.
|
|
51
|
+
An api_key is tenant-wide and must never reach a client. The SDK asks again with
|
|
52
|
+
`forceRefresh` after a 401, so expiry is handled for you.
|
|
53
|
+
|
|
54
|
+
## Two hooks, and you hold the id between them
|
|
55
|
+
|
|
56
|
+
There is no hook that owns "which conversation is open". That is your app's
|
|
57
|
+
state — component state in the example above, a route param in a real one — and
|
|
58
|
+
keeping it out of the package is what lets `/c/:id` work without syncing
|
|
59
|
+
anything.
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
63
|
+
|
|
64
|
+
const list = useConversationList();
|
|
65
|
+
const conversation = useConversation(selectedId, {
|
|
66
|
+
agentSlug: "support",
|
|
67
|
+
onCreated: (created) => setSelectedId(created.id),
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The one behaviour worth knowing: **a conversation is created on the first
|
|
72
|
+
message, not when the user clicks "new chat".** Otherwise every abandoned blank
|
|
73
|
+
slate leaves an empty row in the list. `send` covers both cases, so your UI never
|
|
74
|
+
special-cases "no conversation yet" — it just passes `null` until `onCreated`
|
|
75
|
+
fires. The messages already on screen survive that hand-off.
|
|
76
|
+
|
|
77
|
+
Both hooks paginate, in opposite directions, so the names stay apart:
|
|
78
|
+
`list.hasMore` / `list.loadMore` walk the **conversation list**;
|
|
79
|
+
`conversation.hasOlder` / `conversation.loadOlder` walk the **open
|
|
80
|
+
conversation's history**.
|
|
81
|
+
|
|
82
|
+
## Infinite history, live present
|
|
83
|
+
|
|
84
|
+
`useConversation` opens with one page of history and points the live stream at
|
|
85
|
+
the end of that page, so a year-old conversation costs one page on open instead
|
|
86
|
+
of the whole log — and keeps costing one page on every reconnect.
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
const { messages, hasOlder, isLoadingOlder, loadOlder } = useConversation(id);
|
|
90
|
+
|
|
91
|
+
// Whatever your list uses to notice the top: a scroll handler, an
|
|
92
|
+
// IntersectionObserver on a sentinel, FlatList's onEndReached inverted.
|
|
93
|
+
const onReachedTop = () => {
|
|
94
|
+
if (hasOlder && !isLoadingOlder) void loadOlder();
|
|
95
|
+
};
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Two details that are handled for you and one that is not:
|
|
99
|
+
|
|
100
|
+
- **No gap at the junction.** The stream resumes from the page's cursor, and the
|
|
101
|
+
server replays everything past it before going live, so a message sent while
|
|
102
|
+
the page was in flight arrives rather than vanishing.
|
|
103
|
+
- **Order is stable.** Pages, live frames and your own optimistic message are
|
|
104
|
+
merged by id and sorted by sequence, so a page landing mid-turn can't shuffle
|
|
105
|
+
the transcript.
|
|
106
|
+
- **Scroll anchoring is yours.** Prepending grows the list upward while the
|
|
107
|
+
scroll offset stays put, which reads as a jump. Record `scrollHeight` before
|
|
108
|
+
calling `loadOlder` and add the difference back in a layout effect —
|
|
109
|
+
`packages/demo/src/chat/message-list.tsx` does exactly that.
|
|
110
|
+
|
|
111
|
+
Reopening a conversation the user has already visited costs no request at all:
|
|
112
|
+
the base SDK caches loaded conversations (in memory by default) and these hooks
|
|
113
|
+
drive it for you — reading on open, recording as messages arrive, and resuming
|
|
114
|
+
the stream from the cached cursor so nothing is replayed. Configure or replace it
|
|
115
|
+
on the client (`cache`, `cacheMessageLimit`); see the `@cubos/agent-sdk` README.
|
|
116
|
+
|
|
117
|
+
`loadOlder` resolves to how many messages it added. A page counts **events**, not
|
|
118
|
+
messages, so a page full of tool calls can add none while `hasOlder` stays true —
|
|
119
|
+
keep going rather than treating 0 as the end.
|
|
120
|
+
|
|
121
|
+
## Your components in the agent's replies
|
|
122
|
+
|
|
123
|
+
Hand the hook a map of **renderers** plus the **component libraries** this screen
|
|
124
|
+
can draw, and the agent may use those components in a reply:
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
const { messages, renderMessage } = useConversation(id, {
|
|
128
|
+
components: {
|
|
129
|
+
Choices: (props) => <Choices {...props} onPick={send} />,
|
|
130
|
+
},
|
|
131
|
+
componentLibraries: ["support-widgets"],
|
|
132
|
+
renderMarkdown: (text) => <Markdown>{text}</Markdown>,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// In your bubble. Null means the message has no blocks — every user message —
|
|
136
|
+
// so that is where your own rendering of `content` goes.
|
|
137
|
+
{renderMessage(message) ?? message.content}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The two halves have different owners, and that is deliberate. What the agent is
|
|
141
|
+
*told* about a component — its summary, what each prop means, the JSON Schema,
|
|
142
|
+
the example — lives in a component library that an operator authors over the
|
|
143
|
+
admin API, because those words go into the model's context verbatim. What
|
|
144
|
+
*draws* it is yours. You choose which libraries apply to the screen you have
|
|
145
|
+
open; the hook enables them on the conversation, at creation for a chat that
|
|
146
|
+
starts here and with a `PUT` when the list changes.
|
|
147
|
+
|
|
148
|
+
The server refuses any component the enabled libraries don't resolve to, and
|
|
149
|
+
validates props against the library's schema before the message is written — so
|
|
150
|
+
a renderer never sees a shape it didn't ask for. A tag with no renderer draws
|
|
151
|
+
nothing, and the hook logs a warning naming it, since that is a silent gap in a
|
|
152
|
+
reply. The reverse is never warned about: **keep renderers for components no
|
|
153
|
+
library offers any more**, because that is what draws the older messages in the
|
|
154
|
+
transcript.
|
|
155
|
+
|
|
156
|
+
`renderMarkdown` draws the prose around the components. Without it the markdown
|
|
157
|
+
renders as plain text: this package ships no parser, and your app already has
|
|
158
|
+
one.
|
|
159
|
+
|
|
160
|
+
## Client tools: functions the agent calls in your app
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
useConversation(id, {
|
|
164
|
+
clientTools: {
|
|
165
|
+
get_browser_context: {
|
|
166
|
+
description: "Timezone, locale and local time, as this browser reports them.",
|
|
167
|
+
inputSchema: { type: "object", properties: {} },
|
|
168
|
+
readOnlyHint: true,
|
|
169
|
+
handler: () => ({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }),
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
onClientToolError: (err) => console.error(err),
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The agent calls it, **its turn suspends**, your handler runs here, and the turn
|
|
177
|
+
resumes with what you returned. A handler that throws answers with the failure
|
|
178
|
+
rather than leaving the agent to wait out its deadline.
|
|
179
|
+
|
|
180
|
+
Two things this does for you that are easy to get wrong by hand:
|
|
181
|
+
|
|
182
|
+
- **One stream, not two.** The runner rides the subscription the hook already
|
|
183
|
+
holds — a `client_tool_call` frame nudges it, and so does every reconnect, so a
|
|
184
|
+
call issued while the connection was down is picked up rather than left to
|
|
185
|
+
expire. Doing this outside the hook means a second SSE connection per
|
|
186
|
+
conversation, against a server that caps how many a user may hold.
|
|
187
|
+
- **Declared before the first turn.** On the blank slate the tools are declared
|
|
188
|
+
in the same breath as creating the conversation, so the agent can already reach
|
|
189
|
+
for them in its first reply.
|
|
190
|
+
|
|
191
|
+
The lease, the renewal while a slow handler runs, and the retry on the result
|
|
192
|
+
POST are the base SDK's `serveClientTools`; the hook only decides when to start
|
|
193
|
+
and stop it.
|
|
194
|
+
|
|
195
|
+
## The surface
|
|
196
|
+
|
|
197
|
+
| Hook | What it gives you |
|
|
198
|
+
|---|---|
|
|
199
|
+
| `useConversationList(opts)` | the live list, plus `archive`, `loadMore` |
|
|
200
|
+
| `useConversation(id \| null, opts)` | messages, todos, activity, `send`, `sendAudio`, `sendImages`, `steer`, `loadOlder`, `renderMessage` |
|
|
201
|
+
| `useIdentity()` | who the token acts as, and which agents it may use |
|
|
202
|
+
| `useAgentClient()` | the underlying `AgentClient`, for anything not wrapped |
|
|
203
|
+
|
|
204
|
+
`useConversation(null)` is valid and opens no connection — that is the blank
|
|
205
|
+
slate before a conversation is picked.
|
|
206
|
+
|
|
207
|
+
Both the list and the conversation are **live**: the hooks load a page of
|
|
208
|
+
history, follow the SSE stream from where that page ends, and resume from the
|
|
209
|
+
exact point after a drop. Nothing to poll, nothing to invalidate.
|
|
210
|
+
|
|
211
|
+
## Messages
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
interface Message {
|
|
215
|
+
id: string;
|
|
216
|
+
role: "user" | "agent";
|
|
217
|
+
content: string;
|
|
218
|
+
attachments: Array<{
|
|
219
|
+
id: string;
|
|
220
|
+
kind: "image" | "audio";
|
|
221
|
+
mimeType: string;
|
|
222
|
+
bytes: number;
|
|
223
|
+
label: string | null;
|
|
224
|
+
}>;
|
|
225
|
+
seq: number;
|
|
226
|
+
at: string;
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Media hangs off `attachments`, and `content` is whatever text came with it — a
|
|
231
|
+
caption for images, the transcription for a voice note. **Both can be empty**, so
|
|
232
|
+
render `attachments` even when `content` is blank.
|
|
233
|
+
|
|
234
|
+
A voice message arrives as one `Message` carrying its clip *and* the
|
|
235
|
+
transcription, which is empty until the agent's STT model finishes — your cue to
|
|
236
|
+
render a player with a "transcribing" state.
|
|
237
|
+
|
|
238
|
+
Bytes come from `useAgentClient().fetchAttachment(conversationId, message.id,
|
|
239
|
+
attachment.id)`, as a `Blob` rather than a URL: the token travels in a header, so
|
|
240
|
+
an `<img src>` pointed at the route would arrive unauthenticated. Wrap it for the
|
|
241
|
+
platform and release it when the view goes away.
|
|
242
|
+
|
|
243
|
+
Sending images is the mirror image of that:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
// Up to 10 as ONE message, so the agent reasons over the set rather than
|
|
247
|
+
// spending a turn per picture. `caption` becomes the message's text.
|
|
248
|
+
await sendImages(
|
|
249
|
+
[{ image: receipt, label: "receipt" }, { image: statement, label: "statement" }],
|
|
250
|
+
"which charge is duplicated?",
|
|
251
|
+
);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
A `label` names an image for the model, which lets it answer about "the receipt"
|
|
255
|
+
instead of "the second image".
|
|
256
|
+
|
|
257
|
+
`send` appends optimistically and reconciles with the server's echo, rolling the
|
|
258
|
+
optimistic copy back if the send fails. A message whose `id` starts with
|
|
259
|
+
`optimistic:` has not been confirmed yet.
|
|
260
|
+
|
|
261
|
+
## Rendering
|
|
262
|
+
|
|
263
|
+
This package deliberately ships no components and no CSS — that is what keeps it
|
|
264
|
+
running wherever React runs. The pieces that are genuinely generic, and do need
|
|
265
|
+
the DOM, live next door in
|
|
266
|
+
[`@cubos/agent-sdk-react-dom`](../sdk-react-dom): `AgentMarkdown` renders an
|
|
267
|
+
agent's reply (GFM, fenced code, TeX) and styles itself.
|
|
268
|
+
|
|
269
|
+
```tsx
|
|
270
|
+
import { AgentMarkdown } from "@cubos/agent-sdk-react-dom";
|
|
271
|
+
|
|
272
|
+
useConversation(id, { renderMarkdown: (text) => <AgentMarkdown>{text}</AgentMarkdown> });
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Chat UI proper — bubbles, composer, conversation list — is where products differ
|
|
276
|
+
most, and a set of styled components for it is either too rigid to use or too
|
|
277
|
+
configurable to maintain.
|
|
278
|
+
|
|
279
|
+
`renderMessage` is the one exception, and it renders nothing of its own: it puts
|
|
280
|
+
*your* components where the agent placed them and hands the prose to *your*
|
|
281
|
+
markdown renderer. `renderBlocks` is the same function without the hook, for a
|
|
282
|
+
message you already hold.
|
|
283
|
+
|
|
284
|
+
For a complete worked example — message bubbles, a composer with image
|
|
285
|
+
attachments and voice recording, agent-rendered components, client tools, the
|
|
286
|
+
conversation list, a responsive drawer — see `packages/demo` in the repository.
|
|
287
|
+
It is written against these hooks and meant to be copied and changed, not
|
|
288
|
+
installed.
|
|
289
|
+
|
|
290
|
+
## React Native
|
|
291
|
+
|
|
292
|
+
The hooks are clean. Two caveats live below them, in the base SDK:
|
|
293
|
+
|
|
294
|
+
- **Streaming.** Live updates use SSE over `fetch` response streams. React
|
|
295
|
+
Native's default networking does not stream response bodies; you need a fetch
|
|
296
|
+
polyfill that does (`react-native-fetch-api` with
|
|
297
|
+
`react-native-polyfill-globals`), handed to the provider as `fetch`.
|
|
298
|
+
- **Media.** `sendAudio` and `sendImages` take `Blob`s, and `fetchAttachment`
|
|
299
|
+
hands one back. Recording, picking an image and turning a blob into something
|
|
300
|
+
displayable are all platform-specific — use what the platform offers.
|
|
301
|
+
|
|
302
|
+
Components and client tools need nothing extra: `renderMessage` builds elements
|
|
303
|
+
with `createElement`, and a handler is a function. Your `render` is a Native
|
|
304
|
+
component like any other.
|
package/dist/blocks.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Block, Message } from "@cubos/agent-sdk";
|
|
2
|
+
import { type ComponentType, type ReactNode } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* What draws one component, keyed by tag name — PascalCase, as the agent writes
|
|
5
|
+
* it.
|
|
6
|
+
*
|
|
7
|
+
* Renderers only. What the *agent* is told about a component (its summary, the
|
|
8
|
+
* prop schema, the example) lives in a component library the operator authors,
|
|
9
|
+
* because those words go into the model's context verbatim and a browser must
|
|
10
|
+
* not be the one writing them. Your app supplies the drawing half and the list
|
|
11
|
+
* of library slugs it can draw; `useConversation` warns when the two disagree.
|
|
12
|
+
*
|
|
13
|
+
* Keep renderers here for components no enabled library offers any more — that
|
|
14
|
+
* is what draws the components of older messages in the transcript.
|
|
15
|
+
*/
|
|
16
|
+
export type ComponentMap = Record<string, ComponentType<any>>;
|
|
17
|
+
export interface RenderBlocksOptions {
|
|
18
|
+
components?: ComponentMap;
|
|
19
|
+
/** Draws the prose between components. Without it the markdown is rendered as
|
|
20
|
+
* plain text — correct, just not formatted; the SDK ships no markdown parser
|
|
21
|
+
* and a chat app already has one. */
|
|
22
|
+
renderMarkdown?: (text: string) => ReactNode;
|
|
23
|
+
/**
|
|
24
|
+
* Wraps each rendered block, given the block it came from.
|
|
25
|
+
*
|
|
26
|
+
* The reason it takes the block and not just the node: laying blocks out
|
|
27
|
+
* needs to know which are prose and which are components — two cards side by
|
|
28
|
+
* side and the paragraph above them full width — and by the time a block is a
|
|
29
|
+
* `ReactNode` that is gone. The SDK will not decide the layout (it renders no
|
|
30
|
+
* DOM at all), so it hands back what the app needs to decide it.
|
|
31
|
+
*/
|
|
32
|
+
wrapBlock?: (node: ReactNode, block: Block) => ReactNode;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The message split into nodes: prose through `renderMarkdown`, each component
|
|
36
|
+
* block through the component registered under its tag.
|
|
37
|
+
*
|
|
38
|
+
* Null when the message carries no blocks — a user message, or an older server.
|
|
39
|
+
* That distinction is real, so it is left to the caller rather than papered over
|
|
40
|
+
* with `content`: a user's own text usually shouldn't go through a markdown
|
|
41
|
+
* renderer at all.
|
|
42
|
+
*
|
|
43
|
+
* A tag with no component renders nothing. It means the set changed after the
|
|
44
|
+
* message was written, and showing raw `<Tag …/>` text would be worse than a
|
|
45
|
+
* gap.
|
|
46
|
+
*/
|
|
47
|
+
export declare function renderBlocks(message: Message, options?: RenderBlocksOptions): ReactNode[] | null;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AgentClient, ClientOptions } from "@cubos/agent-sdk";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
/** An intersection, not `extends`: `ClientOptions` is a union (`getToken` or
|
|
4
|
+
* `token`), and an interface cannot extend one. */
|
|
5
|
+
export type AgentProviderProps = ClientOptions & {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Holds one `AgentClient` for the tree below it.
|
|
10
|
+
*
|
|
11
|
+
* `baseUrl`, `tenant`, `fetch` and `timeoutMs` are identity keys: change one and
|
|
12
|
+
* a new client is built, so every subscription below reconnects. That is right
|
|
13
|
+
* when you switch backend or user, and a waste when it happens because the props
|
|
14
|
+
* object was rebuilt for nothing — keep `fetch` stable if you pass it.
|
|
15
|
+
*
|
|
16
|
+
* The credential is exempt: both `getToken` and `token` reach the client through
|
|
17
|
+
* a ref, so a fresh closure every render (the normal case for an inline arrow)
|
|
18
|
+
* neither rebuilds the client nor pins a stale token.
|
|
19
|
+
*/
|
|
20
|
+
export declare function AgentProvider({ children, ...options }: AgentProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<AgentClient | null>>;
|
|
21
|
+
/** The client from the nearest `AgentProvider`. Throws outside one, because the
|
|
22
|
+
* alternative is a component that silently never loads. */
|
|
23
|
+
export declare function useAgentClient(): AgentClient;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type { Activity, Attachment, AttachmentKind, Block, ClientTool, ClientToolCall, ClientToolContext, Conversation, ConversationEvent, EnabledComponents, Identity, Message, MessagePage, MessageRole, Todo, TodoStatus, } from "@cubos/agent-sdk";
|
|
2
|
+
export type { ComponentMap, RenderBlocksOptions } from "./blocks.js";
|
|
3
|
+
export { renderBlocks } from "./blocks.js";
|
|
4
|
+
export type { AgentProviderProps } from "./context.js";
|
|
5
|
+
export { AgentProvider, useAgentClient } from "./context.js";
|
|
6
|
+
export type { UseConversationOptions, UseConversationResult } from "./use-conversation.js";
|
|
7
|
+
export { useConversation } from "./use-conversation.js";
|
|
8
|
+
export type { UseConversationEventsOptions, UseConversationEventsResult, } from "./use-conversation-events.js";
|
|
9
|
+
export { useConversationEvents } from "./use-conversation-events.js";
|
|
10
|
+
export type { UseConversationListOptions, UseConversationListResult, } from "./use-conversation-list.js";
|
|
11
|
+
export { useConversationList } from "./use-conversation-list.js";
|
|
12
|
+
export type { UseIdentityResult } from "./use-identity.js";
|
|
13
|
+
export { useIdentity } from "./use-identity.js";
|