@dudousxd/nestjs-agent-react 0.5.5 β†’ 0.6.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/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. Ships styling-agnostic
7
- chat components (every action is a callback; all styling via `classNames`) and an optional rich-markdown
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**. `useAgentChat` (state/streaming/persistence) and `AgentClient`
43
- (the REST calls) own all the logic; `MessageList`/`ChatInput` β€” and the `markdown` subpath β€” are an
44
- OPTIONAL, styling-agnostic reference implementation on top. A host with its own design system renders
45
- straight off the hook's return value and never imports `MessageList` at all.
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:berlin Β· 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