@dudousxd/nestjs-agent-react 0.5.5 β 0.6.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/README.md +296 -8
- package/dist/index.cjs +2189 -758
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +980 -224
- package/dist/index.d.ts +980 -224
- package/dist/index.js +2175 -758
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
> πͺΊ Part of the [Aviary](https://davidecarvalho.github.io/aviary) Β· the React frontend for [`@dudousxd/nestjs-agent`](https://www.npmjs.com/package/@dudousxd/nestjs-agent).
|
|
4
4
|
|
|
5
5
|
`useAgentChat` wraps the Vercel **AI SDK v7** `useChat` with a transport for the agent's `/agent/chat`
|
|
6
|
-
SSE, plus threads, personas, quota, cancel, and human-in-the-loop approve/reject.
|
|
7
|
-
|
|
8
|
-
subpath.
|
|
6
|
+
SSE, plus threads, personas, quota, cancel, and human-in-the-loop approve/reject and answer/skip. `useChatTranscript`
|
|
7
|
+
turns the streamed messages into a renderable model β grouped parts, derived values, actions as state
|
|
8
|
+
machines β and the chat components are one rendering of it. Optional rich-markdown subpath.
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -39,10 +39,268 @@ function Chat() {
|
|
|
39
39
|
|
|
40
40
|
## Bring your own UI
|
|
41
41
|
|
|
42
|
-
The package is **headless by design
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
The package is **headless by design**, in four layers, and only the top one renders anything:
|
|
43
|
+
|
|
44
|
+
| Layer | Owns | Renders? |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `AgentClient` | The REST calls β threads, quota, attachments, approve/reject/answer/skip | No |
|
|
47
|
+
| `AgentChatTransport` + `useAgentChat` | SSE parsing, resume/reconnect, thread state, HITL routing | No |
|
|
48
|
+
| `useChatTranscript` / `useTranscriptItem` | The transcript model β grouped parts, per-message derived values, action state machines, windowing, stick-to-bottom | No |
|
|
49
|
+
| `useComposerAutocomplete` | The composer's completion model β which trigger the caret is in, the query, the filtered items, the highlight, what a pick does to the text and caret | No |
|
|
50
|
+
| `MessageList` / `MessageItem` / `ChatInput` / `AgentMarkdown` | One rendering of that model | Yes β and optional |
|
|
51
|
+
|
|
52
|
+
A host with its own design system drives the model and writes its own markup; it never reimplements
|
|
53
|
+
tool grouping, the edit machine, or the copy flash, and it never imports `MessageList`.
|
|
54
|
+
|
|
55
|
+
### The transcript model
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { useAgentChat, useChatTranscript } from '@dudousxd/nestjs-agent-react';
|
|
59
|
+
|
|
60
|
+
function Chat() {
|
|
61
|
+
const chat = useAgentChat({ baseUrl: '/agent' });
|
|
62
|
+
const transcript = useChatTranscript({
|
|
63
|
+
messages: chat.messages,
|
|
64
|
+
status: chat.status,
|
|
65
|
+
editable: true,
|
|
66
|
+
onEditSubmit: (id, text) => chat.sendMessage({ text }),
|
|
67
|
+
onFork: (id) => chat.forkThread(threadId, id),
|
|
68
|
+
onStop: () => chat.cancel(),
|
|
69
|
+
getUsage: (message) => readUsage(message),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div {...transcript.scroll.getContainerProps()} className="my-scroller">
|
|
74
|
+
{transcript.window.canLoadEarlier ? (
|
|
75
|
+
<button onClick={transcript.window.loadEarlier}>
|
|
76
|
+
{transcript.window.hiddenCount} earlier
|
|
77
|
+
</button>
|
|
78
|
+
) : null}
|
|
79
|
+
|
|
80
|
+
{transcript.items.map((item) => (
|
|
81
|
+
<article key={item.id} className={item.isUser ? 'bubble-user' : 'bubble-assistant'}>
|
|
82
|
+
{item.blocks.map((block) => {
|
|
83
|
+
if (block.kind === 'text') return <MyMarkdown key={block.key}>{block.text}</MyMarkdown>;
|
|
84
|
+
if (block.kind === 'tools') return <MyToolGroup key={block.key} parts={block.parts} />;
|
|
85
|
+
return (
|
|
86
|
+
<MyDisclosure key={block.key} open={block.isOpen} onToggle={() => block.toggle()}>
|
|
87
|
+
{block.text}
|
|
88
|
+
</MyDisclosure>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
{item.copy.available ? (
|
|
92
|
+
<button onClick={item.copy.copy}>{item.copy.copied ? 'Copied' : 'Copy'}</button>
|
|
93
|
+
) : null}
|
|
94
|
+
{item.usage ? <span>{item.usage.costLabel} Β· {item.usage.tokensLabel}</span> : null}
|
|
95
|
+
</article>
|
|
96
|
+
))}
|
|
97
|
+
|
|
98
|
+
{transcript.scroll.showJumpToLatest ? (
|
|
99
|
+
<button onClick={transcript.scroll.scrollToBottom}>Jump to latest</button>
|
|
100
|
+
) : null}
|
|
101
|
+
{transcript.stop.available ? (
|
|
102
|
+
<button onClick={transcript.stop.stop}>
|
|
103
|
+
{transcript.stop.isStopping ? 'Stoppingβ¦' : 'Stop'}
|
|
104
|
+
</button>
|
|
105
|
+
) : null}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
| On the instance | What it is |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `items` | The mounted window, oldest first. Each item carries `blocks`, `text`, `usage`, `timestamp`, `isStreaming`, `isLastAssistant`, and its action machines. |
|
|
114
|
+
| `items[i].blocks` | `{ kind: 'text' \| 'reasoning' \| 'tools' \| 'sources' \| 'elicitation' }`. A `tools` block is a run of CONSECUTIVE tool parts β any other part between two calls (including a `step-start` marker) ends the run. A `reasoning` block carries `isOpen`/`toggle`, open while it streams. A `sources` block appears only under `sources: true`, an `elicitation` block only under `onAnswer` β see below. |
|
|
115
|
+
| `items[i].blocks[n]` (`tools`) | Also carries `calls`: the same parts, each with `{ toolCallId, name, isAwaitingApproval, approve, reject, error }`. |
|
|
116
|
+
| `items[i].copy` | `{ available, copied, copy() }` β `copied` flashes for `copyResetMs` (default 1500). |
|
|
117
|
+
| `items[i].edit` | `{ available, isEditing, draft, canSave, start(), cancel(), setDraft(), save(), getTextareaProps() }`. The prop-getter focuses with the caret at the end, saves on Enter, cancels on Escape. |
|
|
118
|
+
| `items[i].fork` / `.regenerate` | `{ available, run() }`. Regenerate is offered on the last assistant message only. |
|
|
119
|
+
| `items[i].usage` / `.timestamp` | `{ totalTokens, costLabel, tokensLabel }` / `{ relative, absolute, date }` β the derivations, not the markup. |
|
|
120
|
+
| `window` | `{ visibleCount, hiddenCount, canLoadEarlier, loadEarlier(), showAll() }`. |
|
|
121
|
+
| `scroll` | `useStickToBottom` β `{ isAtBottom, showJumpToLatest, scrollToBottom(), getContainerProps() }`. |
|
|
122
|
+
| `stop` | `{ available, isStopping, stop() }` over `useAgentChat`'s `cancel`. |
|
|
123
|
+
| `showEmptyState` / `showTypingIndicator` / `showFollowUps` | List-level "should this be on screen" booleans. |
|
|
124
|
+
|
|
125
|
+
`useTranscriptItem({ message })` is the single-message half, for a host that lays out the list
|
|
126
|
+
itself. `MessageItemView({ item })` is the default markup for one modelled item β drive the model
|
|
127
|
+
yourself and still render the shipped bubble.
|
|
128
|
+
|
|
129
|
+
### Where the answer came from
|
|
130
|
+
|
|
131
|
+
RAG persists its retrieval as an auto-executed tool call whose output is `{ passages }` β inject
|
|
132
|
+
mode records it as `retrieve`, `createRetrievalTool` under whatever name the host chose. Pass
|
|
133
|
+
`sources: true` and the model lifts those parts into a `sources` block, aggregated by origin:
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
const transcript = useChatTranscript({ messages, status, sources: true });
|
|
137
|
+
// block.kind === 'sources' β { query, sources: [{ label, passageCount, topScore, passages }], passageCount }
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Detection is structural rather than by tool name, so a renamed retrieval tool still renders as
|
|
141
|
+
provenance. It is off by default: with it off those parts stay in the tool run, where an existing
|
|
142
|
+
tool renderer already receives them.
|
|
143
|
+
|
|
144
|
+
### When the run stops for a person
|
|
145
|
+
|
|
146
|
+
Two things park a run on a human, and both are state on the transcript β not a modal you wire
|
|
147
|
+
yourself, and not a panel beside the conversation.
|
|
148
|
+
|
|
149
|
+
**A question set.** An agent's configured intake, or the model's own `ask` tool, posts questions and
|
|
150
|
+
parks the run until someone settles them. Pass `onAnswer` and the model lifts that tool part out of
|
|
151
|
+
the tool run into an `elicitation` block:
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
const transcript = useChatTranscript({
|
|
155
|
+
messages, status,
|
|
156
|
+
onAnswer: (toolCallId, answers) => chat.answer({ toolCallId, answers }),
|
|
157
|
+
onSkip: (toolCallId) => chat.skip({ toolCallId }),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// block.kind === 'elicitation' β
|
|
161
|
+
// { toolCallId, preamble, questionCount, isPending, outcome, error,
|
|
162
|
+
// questions: [{ id, prompt, position, multiple, selected, isPristine,
|
|
163
|
+
// options: [{ value, label, hotkey, isSelected, isDefault, select() }] }],
|
|
164
|
+
// answer: { available, isSubmitting, run() }, skip: { β¦ } }
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Every option the agent pre-picked comes back as `isDefault` and starts `isSelected`, so confirming
|
|
168
|
+
is enough. `answer.run()` submits **only the questions the user touched** β the rest are omitted on
|
|
169
|
+
purpose, so the server applies the same defaults it showed and the settled row still records which
|
|
170
|
+
ones a human actually decided. A settled set keeps its block (`isPending: false`, `outcome` filled,
|
|
171
|
+
`selected` showing what was chosen), so one piece of markup renders both states.
|
|
172
|
+
|
|
173
|
+
Detection is structural rather than by tool name β an intake and an `ask` persist under whatever
|
|
174
|
+
name their row holds β and lifting is opt-in on `onAnswer` for the same reason `sources` is: with
|
|
175
|
+
nowhere to send an answer, a question set is still just a tool card.
|
|
176
|
+
|
|
177
|
+
**A tool call awaiting approval.** Wire `onApprove`/`onReject` and every call in a `tools` block
|
|
178
|
+
carries its own decision:
|
|
179
|
+
|
|
180
|
+
```tsx
|
|
181
|
+
{block.calls.map((call) => (
|
|
182
|
+
<MyToolCard key={call.toolCallId} part={call.part}>
|
|
183
|
+
{call.isAwaitingApproval ? (
|
|
184
|
+
<>
|
|
185
|
+
<button onClick={call.approve.run} disabled={call.approve.isSubmitting}>Approve</button>
|
|
186
|
+
<button onClick={call.reject.run}>Reject</button>
|
|
187
|
+
{call.error ? <p role="alert">{call.error}</p> : null}
|
|
188
|
+
</>
|
|
189
|
+
) : null}
|
|
190
|
+
</MyToolCard>
|
|
191
|
+
))}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
An `action` tool's input lands and its output never follows on its own β the loop waits for a person
|
|
195
|
+
between the two β so a call stuck at `input-available` IS the pending approval. A question set parks
|
|
196
|
+
the same way and is deliberately excluded: approving one settles nothing.
|
|
197
|
+
|
|
198
|
+
A refused settlement (403 "not your thread", a network failure) lands on `block.error` /
|
|
199
|
+
`call.error` with the affordance still live, rather than escaping as an unhandled rejection. Render
|
|
200
|
+
it β a button that silently does nothing is indistinguishable from a broken one.
|
|
201
|
+
|
|
202
|
+
### Completing as you type
|
|
203
|
+
|
|
204
|
+
`useComposerAutocomplete` is the state machine behind a `/`-style menu in the composer. It is
|
|
205
|
+
source-agnostic on purpose: a host supplies **sources**, each with a trigger character, and the same
|
|
206
|
+
machine drives `@` for a mention as drives `/` for a command. Nothing in it knows what a skill is.
|
|
207
|
+
|
|
208
|
+
```tsx
|
|
209
|
+
import { useComposerAutocomplete, type AutocompleteSource } from '@dudousxd/nestjs-agent-react';
|
|
210
|
+
|
|
211
|
+
const skills: AutocompleteSource = {
|
|
212
|
+
id: 'skills',
|
|
213
|
+
label: 'Skills',
|
|
214
|
+
trigger: '/',
|
|
215
|
+
position: 'start',
|
|
216
|
+
getItems: async (query, signal) => {
|
|
217
|
+
const rows = await fetch(`/agent/skills?q=${query}`, { signal }).then((r) => r.json());
|
|
218
|
+
return rows.map((row) => ({ id: row.name, label: row.name, description: row.description }));
|
|
219
|
+
},
|
|
220
|
+
// The endpoint already matched; re-filtering here would undo it.
|
|
221
|
+
filter: (items) => items,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const autocomplete = useComposerAutocomplete({ value: draft, onValueChange: setDraft, sources: [skills] });
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
| On the instance | What it is |
|
|
228
|
+
|---|---|
|
|
229
|
+
| `isOpen` / `source` / `trigger` / `query` | Whether the caret is inside a live trigger token, and which one |
|
|
230
|
+
| `items` / `activeIndex` / `activeItem` | The filtered candidates and the highlight |
|
|
231
|
+
| `isLoading` / `error` | An async source that has not answered, and one that threw |
|
|
232
|
+
| `highlight(i)` / `moveHighlight(Β±1)` | Move the highlight; `moveHighlight` wraps at both ends |
|
|
233
|
+
| `accept(item?)` | Insert β the highlighted item by default. A click handler calls this directly |
|
|
234
|
+
| `dismiss()` | Close for the token being typed, without touching the text |
|
|
235
|
+
| `getInputProps()` | The textarea's combobox wiring (`role`, `aria-expanded`, `aria-controls`, `aria-activedescendant`) plus the keys the menu owns |
|
|
236
|
+
| `getListboxProps()` / `getOptionProps(i)` | `role="listbox"` / `role="option"` with the ids `aria-activedescendant` points at |
|
|
237
|
+
|
|
238
|
+
**The trigger rule is per source, and there is no default.** `position: 'start'` fires only as the
|
|
239
|
+
first character of the input β `/deploy` is a command, `src/foo` and `and/or` are not. `position:
|
|
240
|
+
'word'` fires at the start of any word, which is how `@ada` reads mid-sentence while `ada@example`
|
|
241
|
+
does not. Getting this wrong breaks the thing people type most, so the source has to say.
|
|
242
|
+
|
|
243
|
+
**Insertion** replaces the token, keeps the trigger, and appends a space: `/roll` + *rollback* β
|
|
244
|
+
`/rollback ` with the caret after the space, and anything already to the right of the caret is left
|
|
245
|
+
where it was. The space both closes the menu and puts the caret where the command's argument goes,
|
|
246
|
+
so typing straight on is free text. A source that wants the menu to stay open sets `insertSuffix: ''`.
|
|
247
|
+
|
|
248
|
+
**Keys.** β/β move (wrapping), Enter and Tab accept, Escape closes for that token. Shift+Tab is left
|
|
249
|
+
alone β a completion menu is not a focus trap. Every key the menu takes is `preventDefault`ed, which
|
|
250
|
+
is how a composer that sends on Enter knows to stand down:
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
function onKeyDown(event) {
|
|
254
|
+
inputProps.onKeyDown(event);
|
|
255
|
+
if (event.defaultPrevented) return; // the menu took it
|
|
256
|
+
if (event.key === 'Enter' && !event.shiftKey) submit();
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**Async sources** are asked per query with an `AbortSignal`; an answer to a query the user has
|
|
261
|
+
already typed past is discarded rather than allowed to overwrite a newer list, and its request is
|
|
262
|
+
aborted. A source that throws leaves the draft alone, reports itself on `error`, and calls `onError`
|
|
263
|
+
β typing and sending carry on.
|
|
264
|
+
|
|
265
|
+
**Skills are one source, not a special case.** `createSkillsSource` is `GET /agent/skills` β the
|
|
266
|
+
scope-resolved list of what THIS actor can invoke, built by the same call that offers the catalog to
|
|
267
|
+
the model, so a `/` menu and the agent's own reach cannot drift apart:
|
|
268
|
+
|
|
269
|
+
```tsx
|
|
270
|
+
import { createSkillsSource, useAgentChat } from '@dudousxd/nestjs-agent-react';
|
|
271
|
+
|
|
272
|
+
const chat = useAgentChat({ baseUrl: '/agent', onThreadCreated: setThreadId });
|
|
273
|
+
// Identity-stable so the list is read once per thread, not once per keystroke.
|
|
274
|
+
const sources = useMemo(
|
|
275
|
+
() => [createSkillsSource({ client: chat.client, getThreadId: () => threadId })],
|
|
276
|
+
[chat.client],
|
|
277
|
+
);
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Each item carries its provenance twice: as a `hint` the rendered row right-aligns
|
|
281
|
+
(`tenant:base-7 Β· overrides global`), and raw on `data` as `{ scope, shadows? }` for a host that
|
|
282
|
+
wants to draw it differently. `shadows` is present only on a clash, so "there is no org default" and
|
|
283
|
+
"there is one and yours wins" stay distinguishable. The list arrives ordered most-specific-scope
|
|
284
|
+
first, then alphabetically β that order IS the precedence, and it is passed through untouched. The
|
|
285
|
+
type-ahead narrows the list it received; it never re-derives which skills apply.
|
|
286
|
+
|
|
287
|
+
The shadcn `ChatComposer` wires all of this for you: pass `autocompleteSources` and it renders the
|
|
288
|
+
menu (`ChatCommandPalette`, grown into the combobox's listbox) above the composer card.
|
|
289
|
+
|
|
290
|
+
### Designed components, as copy-in source
|
|
291
|
+
|
|
292
|
+
`MessageList`/`MessageItem`/`ChatInput` are deliberately minimal. For a styled, accessible chat
|
|
293
|
+
surface over the same model β welcome state with mode pills, a composer card with a context strip,
|
|
294
|
+
grouped tool cards, a reasoning disclosure, provenance, follow-ups, stick-to-bottom β there is a
|
|
295
|
+
[shadcn registry](https://davidecarvalho.github.io/aviary/docs/agent/guides/chat-components) whose
|
|
296
|
+
components are copied into your project rather than imported from here:
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
npx shadcn@latest add https://davidecarvalho.github.io/aviary/r/agent-chat.json
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Tailwind classes belong in the tree Tailwind already scans, which is why they ship as source and not
|
|
303
|
+
as an export of this package.
|
|
46
304
|
|
|
47
305
|
### `useAgentChat` on its own
|
|
48
306
|
|
|
@@ -91,10 +349,18 @@ function CustomChat({ threadId }: { threadId?: string }) {
|
|
|
91
349
|
}
|
|
92
350
|
```
|
|
93
351
|
|
|
352
|
+
`sendMessage` and `regenerate` refuse a turn while one is already in flight, and a resume never
|
|
353
|
+
attaches to a run this session is already streaming. The AI SDK keeps one in-flight response per
|
|
354
|
+
chat and decides whether it replaces the list's last entry or is appended by looking at that last
|
|
355
|
+
entry alone, so two overlapping turns append alternating copies of each other and the list ends up
|
|
356
|
+
with several entries under one id β React's "two children with the same key". A composer that
|
|
357
|
+
disables itself while busy never noticed; a double-submitted one, and StrictMode's doubled resume
|
|
358
|
+
effect, did.
|
|
359
|
+
|
|
94
360
|
`chat` is the AI SDK v7 `useChat` return value (`messages`, `status`, `sendMessage`, `stop`, β¦) spread
|
|
95
361
|
together with the extras: `runId`/`activeRunId`, `client` (the raw `AgentClient`), thread list/CRUD
|
|
96
362
|
(`threads`, `loadThreads`, `loadThread`, `deleteThread`, `forkThread`, `renameThread`, `promoteThread`,
|
|
97
|
-
`truncateFromMessage`), `quota`/`loadQuota`, `cancel`, HITL `approve`/`reject`, and `regenerate`.
|
|
363
|
+
`truncateFromMessage`), `quota`/`loadQuota`, `cancel`, HITL `approve`/`reject` and `answer`/`skip`, and `regenerate`.
|
|
98
364
|
`MyToolCard`'s `part` prop above types as the exported `AnyToolUIPart` (`ToolUIPart | DynamicToolUIPart`
|
|
99
365
|
β `MessageItem` uses the same union for its own `renderToolPart` callback).
|
|
100
366
|
|
|
@@ -146,6 +412,11 @@ await chat.sendMessage({ text: 'what is in this?' }, { body: { attachments: [att
|
|
|
146
412
|
// Human-in-the-loop, callable from any component β not just the chat screen that raised it:
|
|
147
413
|
await chat.approve({ toolCallId });
|
|
148
414
|
await chat.reject({ toolCallId, reason: 'not now' });
|
|
415
|
+
|
|
416
|
+
// The same, for a parked question set. Omitting `answers` confirms every pre-picked answer;
|
|
417
|
+
// `skip` proceeds on those values while recording that the user declined to choose them.
|
|
418
|
+
await chat.answer({ toolCallId, answers: { scope: ['file'] } });
|
|
419
|
+
await chat.skip({ toolCallId });
|
|
149
420
|
```
|
|
150
421
|
|
|
151
422
|
### What a flip-style host owns
|
|
@@ -156,6 +427,23 @@ Everything visual: bubble layout and markdown rendering, the tool-card UI (branc
|
|
|
156
427
|
`MessageList`/`ChatInput`; what it owns is the protocol: SSE parsing, resume/reconnect, thread CRUD,
|
|
157
428
|
quota, and HITL routing.
|
|
158
429
|
|
|
430
|
+
### Reasoning and stop
|
|
431
|
+
|
|
432
|
+
Reasoning frames arrive as `reasoning` parts on the message (the transport maps the backend's
|
|
433
|
+
`reasoning` frame to the AI SDK's `reasoning-start`/`reasoning-end` chunks). `MessageItem` renders
|
|
434
|
+
each run behind a disclosure toggle β open while it streams, folded once the answer lands β with
|
|
435
|
+
`renderReasoning` and `reasoningLabel` slots to override the body and the label.
|
|
436
|
+
|
|
437
|
+
`ChatInput` surfaces cancel next to send:
|
|
438
|
+
|
|
439
|
+
```tsx
|
|
440
|
+
<ChatInput
|
|
441
|
+
onSubmit={(text) => chat.sendMessage({ text })}
|
|
442
|
+
isStreaming={chat.status === 'streaming'}
|
|
443
|
+
onStop={() => chat.cancel()}
|
|
444
|
+
/>
|
|
445
|
+
```
|
|
446
|
+
|
|
159
447
|
### Optional markdown subpath
|
|
160
448
|
|
|
161
449
|
```tsx
|