@gravity-ui/aikit 2.3.0 → 2.4.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.
Files changed (56) hide show
  1. package/build/cjs/components/organisms/AssistantMessage/defaultMessageTypeRegistry.js +6 -0
  2. package/build/cjs/components/organisms/AssistantMessage/defaultMessageTypeRegistry.js.map +1 -1
  3. package/build/cjs/hooks/index.d.ts +2 -0
  4. package/build/cjs/hooks/index.js +2 -0
  5. package/build/cjs/hooks/index.js.map +1 -1
  6. package/build/cjs/hooks/useToolResultContinuation.d.ts +31 -0
  7. package/build/cjs/hooks/useToolResultContinuation.js +91 -0
  8. package/build/cjs/hooks/useToolResultContinuation.js.map +1 -0
  9. package/build/cjs/hooks/useToolset.d.ts +24 -0
  10. package/build/cjs/hooks/useToolset.js +26 -0
  11. package/build/cjs/hooks/useToolset.js.map +1 -0
  12. package/build/cjs/package.json +1 -1
  13. package/build/cjs/utils/index.d.ts +1 -0
  14. package/build/cjs/utils/index.js +1 -0
  15. package/build/cjs/utils/index.js.map +1 -1
  16. package/build/cjs/utils/messageTypeRegistry.d.ts +8 -1
  17. package/build/cjs/utils/messageTypeRegistry.js +14 -2
  18. package/build/cjs/utils/messageTypeRegistry.js.map +1 -1
  19. package/build/cjs/utils/toolset/i18n/en.json +3 -0
  20. package/build/cjs/utils/toolset/i18n/index.d.ts +13 -0
  21. package/build/cjs/utils/toolset/i18n/index.js +10 -0
  22. package/build/cjs/utils/toolset/i18n/index.js.map +1 -0
  23. package/build/cjs/utils/toolset/i18n/ru.json +3 -0
  24. package/build/cjs/utils/toolset/index.d.ts +142 -0
  25. package/build/cjs/utils/toolset/index.js +183 -0
  26. package/build/cjs/utils/toolset/index.js.map +1 -0
  27. package/build/esm/components/organisms/AssistantMessage/defaultMessageTypeRegistry.js +6 -0
  28. package/build/esm/components/organisms/AssistantMessage/defaultMessageTypeRegistry.js.map +1 -1
  29. package/build/esm/hooks/index.d.ts +2 -0
  30. package/build/esm/hooks/index.js +2 -0
  31. package/build/esm/hooks/index.js.map +1 -1
  32. package/build/esm/hooks/useToolResultContinuation.d.ts +31 -0
  33. package/build/esm/hooks/useToolResultContinuation.js +87 -0
  34. package/build/esm/hooks/useToolResultContinuation.js.map +1 -0
  35. package/build/esm/hooks/useToolset.d.ts +24 -0
  36. package/build/esm/hooks/useToolset.js +23 -0
  37. package/build/esm/hooks/useToolset.js.map +1 -0
  38. package/build/esm/package.json +1 -1
  39. package/build/esm/utils/index.d.ts +1 -0
  40. package/build/esm/utils/index.js +1 -0
  41. package/build/esm/utils/index.js.map +1 -1
  42. package/build/esm/utils/messageTypeRegistry.d.ts +8 -1
  43. package/build/esm/utils/messageTypeRegistry.js +14 -2
  44. package/build/esm/utils/messageTypeRegistry.js.map +1 -1
  45. package/build/esm/utils/toolset/i18n/en.json +3 -0
  46. package/build/esm/utils/toolset/i18n/index.d.ts +13 -0
  47. package/build/esm/utils/toolset/i18n/index.js +6 -0
  48. package/build/esm/utils/toolset/i18n/index.js.map +1 -0
  49. package/build/esm/utils/toolset/i18n/ru.json +3 -0
  50. package/build/esm/utils/toolset/index.d.ts +142 -0
  51. package/build/esm/utils/toolset/index.js +176 -0
  52. package/build/esm/utils/toolset/index.js.map +1 -0
  53. package/docs/GENUI.md +627 -0
  54. package/docs/HOOKS.md +65 -11
  55. package/llms.txt +2 -1
  56. package/package.json +11 -1
package/docs/GENUI.md ADDED
@@ -0,0 +1,627 @@
1
+ # Generative UI (toolset)
2
+
3
+ > Render LLM `tool_calls` as typed React components using the existing assistant
4
+ > `tool` content type. AIKit ships `defineTool`, `createToolset`,
5
+ > `createToolsetRenderer`, `applyToolResult`, `toolsetToOpenAIDefinitions`, and
6
+ > `useToolset` / `useToolResultContinuation` — no separate GenUI package.
7
+
8
+ ---
9
+
10
+ ## What ships in the library
11
+
12
+ | Export | Module |
13
+ | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
14
+ | `defineTool`, `createToolset`, `createToolsetRenderer`, `applyToolResult`, `toolsetToOpenAIDefinitions`, types | `src/utils/toolset` (re-exported from `@gravity-ui/aikit`) |
15
+ | `useToolset` | `src/hooks/useToolset.ts` |
16
+ | `useToolResultContinuation` | `src/hooks/useToolResultContinuation.ts` |
17
+
18
+ **Storybook (no network):** `genui/Showcase` (in
19
+ `src/utils/toolset/__stories__/Showcase.stories.tsx`) and `genui/useToolset` (in
20
+ `src/hooks/__stories__/useToolset.stories.tsx`). `Showcase` shows the registry
21
+ directly via `createToolsetRenderer`; `useToolset` is the recommended path via
22
+ the `useToolset` hook.
23
+
24
+ ---
25
+
26
+ ## Mental model
27
+
28
+ 1. The model (or your backend) emits assistant messages whose `content` includes
29
+ `{ type: 'tool', data: { toolName, toolCallId, args, status, ... } }` parts.
30
+ 2. A **toolset** maps `toolName` → validator + React component + optional `execute`
31
+ (shape the payload returned to the model).
32
+ 3. `createToolsetRenderer` / `useToolset` register a `tool` dispatcher on
33
+ `MessageRendererRegistry` so `AssistantMessage` renders your components.
34
+ 4. On `submitResult`, the renderer runs `execute` and emits a result event.
35
+ `useToolset` wires that event to `applyToolResult`; use
36
+ `useToolResultContinuation` to observe the resulting pending → terminal status
37
+ change and POST the updated transcript to your chat API.
38
+
39
+ ---
40
+
41
+ ## Minimal wiring (Storybook-style)
42
+
43
+ Same pattern as the in-repo story — one message, manual history updates:
44
+
45
+ ```tsx
46
+ import {
47
+ AssistantMessage,
48
+ createToolset,
49
+ createToolsetRenderer,
50
+ defineTool,
51
+ type ToolComponentProps,
52
+ type ToolPartContent,
53
+ } from '@gravity-ui/aikit';
54
+
55
+ type ApprovalArgs = {summary: string};
56
+ type ApprovalResult = {approved: boolean; auditText: string};
57
+
58
+ function ApprovalCard({
59
+ args,
60
+ result,
61
+ submitResult,
62
+ }: ToolComponentProps<ApprovalArgs, ApprovalResult>) {
63
+ if (result) return <div>{result.auditText}</div>;
64
+ return (
65
+ <div>
66
+ <p>{args.summary}</p>
67
+ <button type="button" onClick={() => submitResult({approved: true, auditText: ''})}>
68
+ Approve
69
+ </button>
70
+ </div>
71
+ );
72
+ }
73
+
74
+ const toolset = createToolset(
75
+ defineTool({
76
+ name: 'approval.request',
77
+ description: 'Ask the user to approve or reject an action.',
78
+ parameters: {
79
+ type: 'object',
80
+ properties: {summary: {type: 'string'}},
81
+ required: ['summary'],
82
+ },
83
+ schema: {
84
+ validate: (input) => {
85
+ if (
86
+ !input ||
87
+ typeof input !== 'object' ||
88
+ typeof (input as ApprovalArgs).summary !== 'string'
89
+ ) {
90
+ return {success: false, error: {message: 'Expected args.summary to be a string'}};
91
+ }
92
+ return {success: true, data: input as ApprovalArgs};
93
+ },
94
+ },
95
+ component: ApprovalCard,
96
+ execute: ({args, result}) => ({
97
+ approved: result.approved,
98
+ auditText: `${result.approved ? 'Approved' : 'Rejected'} "${args.summary}".`,
99
+ }),
100
+ }),
101
+ );
102
+
103
+ // Pass messageRendererRegistry into AssistantMessage or ChatContainer messageListConfig.
104
+ const registry = createToolsetRenderer(toolset, {
105
+ onToolResult: (event) => {
106
+ // Merge the event into your messages with applyToolResult if needed.
107
+ console.log(event);
108
+ },
109
+ });
110
+ ```
111
+
112
+ For a full chat loop, use `useToolset` instead of hand-rolling `onToolResult` + history merge.
113
+
114
+ ---
115
+
116
+ ## Full chat with `useToolset`
117
+
118
+ ```tsx
119
+ const {messageRendererRegistry} = useToolset<ToolPartContent>({
120
+ toolset,
121
+ setMessages,
122
+ });
123
+
124
+ useToolResultContinuation<ToolPartContent>({
125
+ messages,
126
+ onSettled: ({messages: updated}) => {
127
+ sendTurn(updated).catch(console.warn);
128
+ },
129
+ });
130
+
131
+ <ChatContainer
132
+ messages={messages}
133
+ messageListConfig={{messageRendererRegistry}}
134
+ /* ... */
135
+ />;
136
+ ```
137
+
138
+ Keep a stable `toolset` reference (`useMemo`, `createToolset` at module scope, or
139
+ both). `useToolset` calls `applyToolResult`; `useToolResultContinuation` handles
140
+ the follow-up side effect after a tool reaches `success`, `error`, or `cancelled`.
141
+
142
+ ---
143
+
144
+ ## Reporting tool failures and cancellations
145
+
146
+ By default an `execute` callback that returns `TResult` is treated as `'success'`.
147
+ To report a failure or a user cancellation explicitly, return a
148
+ `ToolExecutionOutcome<TResult>`:
149
+
150
+ ```tsx
151
+ execute: async ({args}) => {
152
+ try {
153
+ const result = await callBackend(args);
154
+ return {status: 'success', result};
155
+ } catch (err) {
156
+ return {status: 'error', error: {message: String(err)}};
157
+ }
158
+ },
159
+ ```
160
+
161
+ A thrown error is also surfaced as `{status: 'error'}` — the wrapper catches it so
162
+ the tool message flips to an error state instead of leaving the UI hanging. Use
163
+ `'cancelled'` when the user rejects an approval or otherwise aborts the action;
164
+ `applyToolResult` will reflect the status on the tool part.
165
+
166
+ ---
167
+
168
+ ## Architecture (with a live model)
169
+
170
+ ```
171
+ User input
172
+ → setMessages([...messages, userMessage])
173
+ → sendTurn(messages)
174
+
175
+ fetch(CHAT_API_URL, { messages, tools, tool_choice: 'auto' }) // your server route
176
+
177
+ Response with tool_calls
178
+ → chatCompletionsToAssistantMessage(body)
179
+ → setMessages([...nextMessages, assistant])
180
+
181
+ messageRendererRegistry dispatches by toolName
182
+ → component receives { args, submitResult }
183
+ → user acts → submitResult(result)
184
+
185
+ useToolset → applyToolResult
186
+
187
+ useToolResultContinuation → sendTurn(updated)
188
+
189
+ fetch(CHAT_API_URL, ...) — history now includes role: 'tool' messages
190
+ → model replies with text or another tool_call
191
+ ```
192
+
193
+ **API keys:** call the provider from your backend (Next.js route, BFF, worker, etc.).
194
+ The browser talks only to your route; never embed provider keys in client bundles.
195
+
196
+ ---
197
+
198
+ ## LLM adapter (app code, OpenAI Chat Completions shape)
199
+
200
+ Provider-specific conversion stays outside the library. Typical responsibilities:
201
+
202
+ - `messagesToChatCompletions` — flatten assistant `text` + `tool` parts into
203
+ `assistant` + `tool` roles; include `tool_calls` and serialized `tool` results.
204
+ - `chatCompletionsToAssistantMessage` — map `tool_calls` into `tool` parts with
205
+ `status: 'waitingConfirmation'` (or `error` on bad JSON).
206
+ - `toolsetToOpenAIDefinitions(toolset)` — library helper that maps a `Toolset` to
207
+ the OpenAI `tools[]` shape.
208
+
209
+ See the reference implementation below for a two-tool weather + approval example.
210
+
211
+ ---
212
+
213
+ ## Reference: live chat component
214
+
215
+ Copy into your app. Set `CHAT_API_URL` to a server route that forwards to your
216
+ model (adds `model`, auth, etc. server-side).
217
+
218
+ ```tsx
219
+ /* eslint-disable no-console */
220
+ import {useCallback, useMemo, useRef, useState} from 'react';
221
+
222
+ import {Button, Card, Text} from '@gravity-ui/uikit';
223
+ import {v4 as uuid} from 'uuid';
224
+
225
+ import {
226
+ ChatContainer,
227
+ type ChatStatus,
228
+ type TAssistantMessage,
229
+ type TChatMessage,
230
+ type TSubmitData,
231
+ type TUserMessage,
232
+ type TextMessageContent,
233
+ type ToolComponentProps,
234
+ type ToolPartContent,
235
+ type ToolSchemaResult,
236
+ createToolset,
237
+ defineTool,
238
+ toolsetToOpenAIDefinitions,
239
+ useToolResultContinuation,
240
+ useToolset,
241
+ } from '@gravity-ui/aikit';
242
+
243
+ // === Provider types (OpenAI Chat Completions shape) ===
244
+
245
+ type JSONSchemaObject = {
246
+ type: 'object';
247
+ properties: Record<string, unknown>;
248
+ required?: string[];
249
+ additionalProperties?: boolean;
250
+ };
251
+
252
+ type ChatCompletionsToolCall = {
253
+ id: string;
254
+ type: 'function';
255
+ function: {name: string; arguments: string};
256
+ };
257
+
258
+ type ChatCompletionsMessage =
259
+ | {role: 'system' | 'user'; content: string}
260
+ | {role: 'assistant'; content: string | null; tool_calls?: ChatCompletionsToolCall[]}
261
+ | {role: 'tool'; tool_call_id: string; content: string};
262
+
263
+ type ChatCompletionsResponse = {
264
+ choices?: Array<{
265
+ message?: {
266
+ role?: string;
267
+ content?: string | null;
268
+ tool_calls?: ChatCompletionsToolCall[];
269
+ };
270
+ finish_reason?: string;
271
+ }>;
272
+ error?: {message?: string} | string;
273
+ };
274
+
275
+ type AssistantContentPart = TextMessageContent | ToolPartContent;
276
+ type AgentChatMessage = TChatMessage<ToolPartContent>;
277
+
278
+ type WeatherArgs = {city: string; value: number; units?: 'c' | 'f'};
279
+ type WeatherResult = {acknowledged: true; auditText: string};
280
+ type ApprovalArgs = {summary: string};
281
+ type ApprovalResult = {approved: boolean; auditText: string};
282
+
283
+ const weatherParameters: JSONSchemaObject = {
284
+ type: 'object',
285
+ properties: {
286
+ city: {type: 'string', description: 'City name, e.g. Berlin.'},
287
+ units: {type: 'string', enum: ['c', 'f']},
288
+ value: {type: 'number', description: 'Temperature value, e.g. 20.'},
289
+ },
290
+ required: ['city', 'value'],
291
+ additionalProperties: false,
292
+ };
293
+
294
+ const approvalParameters: JSONSchemaObject = {
295
+ type: 'object',
296
+ properties: {
297
+ summary: {type: 'string', description: 'One-line summary for user approval.'},
298
+ },
299
+ required: ['summary'],
300
+ additionalProperties: false,
301
+ };
302
+
303
+ function validateWeatherArgs(input: unknown): ToolSchemaResult<WeatherArgs> {
304
+ if (!input || typeof input !== 'object') {
305
+ return {success: false, error: {message: 'Expected object arguments'}};
306
+ }
307
+ const value = input as Record<string, unknown>;
308
+ if (typeof value.city !== 'string') {
309
+ return {success: false, error: {message: 'Expected args.city to be a string'}};
310
+ }
311
+ if (typeof value.value !== 'number') {
312
+ return {success: false, error: {message: 'Expected args.value to be a number'}};
313
+ }
314
+ if (value.units !== undefined && value.units !== 'c' && value.units !== 'f') {
315
+ return {success: false, error: {message: 'Expected args.units to be "c" or "f"'}};
316
+ }
317
+ return {success: true, data: {city: value.city, value: value.value, units: value.units}};
318
+ }
319
+
320
+ function validateApprovalArgs(input: unknown): ToolSchemaResult<ApprovalArgs> {
321
+ if (!input || typeof input !== 'object') {
322
+ return {success: false, error: {message: 'Expected object arguments'}};
323
+ }
324
+ const value = input as Record<string, unknown>;
325
+ if (typeof value.summary !== 'string') {
326
+ return {success: false, error: {message: 'Expected args.summary to be a string'}};
327
+ }
328
+ return {success: true, data: {summary: value.summary}};
329
+ }
330
+
331
+ function WeatherCard({args, result, submitResult}: ToolComponentProps<WeatherArgs, WeatherResult>) {
332
+ return (
333
+ <Card view="outlined" style={{padding: 12}}>
334
+ <Text variant="subheader-1">Weather · {args.city}</Text>
335
+ <Text color="secondary">
336
+ {args.value}°{args.units ?? 'c'}
337
+ </Text>
338
+ {result ? (
339
+ <Text color="secondary">{result.auditText}</Text>
340
+ ) : (
341
+ <Button view="action" onClick={() => submitResult({acknowledged: true, auditText: ''})}>
342
+ Got it
343
+ </Button>
344
+ )}
345
+ </Card>
346
+ );
347
+ }
348
+
349
+ function ApprovalCard({
350
+ args,
351
+ result,
352
+ submitResult,
353
+ }: ToolComponentProps<ApprovalArgs, ApprovalResult>) {
354
+ return (
355
+ <Card view="outlined" style={{padding: 12}}>
356
+ <Text variant="subheader-1">Approval request</Text>
357
+ <Text>{args.summary}</Text>
358
+ {result ? (
359
+ <Text color="secondary">{result.auditText}</Text>
360
+ ) : (
361
+ <div style={{display: 'flex', gap: 8}}>
362
+ <Button view="action" onClick={() => submitResult({approved: true, auditText: ''})}>
363
+ Approve
364
+ </Button>
365
+ <Button onClick={() => submitResult({approved: false, auditText: ''})}>Reject</Button>
366
+ </div>
367
+ )}
368
+ </Card>
369
+ );
370
+ }
371
+
372
+ const toolset = createToolset(
373
+ defineTool({
374
+ name: 'weather_show',
375
+ description: 'Render a weather card for a city and let the user acknowledge it.',
376
+ parameters: weatherParameters,
377
+ schema: {validate: validateWeatherArgs},
378
+ component: WeatherCard,
379
+ execute: ({args, result}) => ({
380
+ acknowledged: result.acknowledged,
381
+ auditText: `User acknowledged weather for ${args.city} (${args.value}°${args.units ?? 'c'}).`,
382
+ }),
383
+ }),
384
+ defineTool({
385
+ name: 'approval_request',
386
+ description: 'Ask the user to approve or reject a proposed action.',
387
+ parameters: approvalParameters,
388
+ schema: {validate: validateApprovalArgs},
389
+ component: ApprovalCard,
390
+ execute: ({args, result}) => ({
391
+ approved: result.approved,
392
+ auditText: `${result.approved ? 'Approved' : 'Rejected'} "${args.summary}" in the client UI.`,
393
+ }),
394
+ }),
395
+ );
396
+
397
+ function toContentArray(
398
+ content: TAssistantMessage<ToolPartContent>['content'],
399
+ ): AssistantContentPart[] {
400
+ if (typeof content === 'string') {
401
+ return content ? [{type: 'text', data: {text: content}}] : [];
402
+ }
403
+ const parts = Array.isArray(content) ? content : [content];
404
+ return parts.flatMap((part): AssistantContentPart[] => {
405
+ if (part.type === 'text' || part.type === 'tool') return [part as AssistantContentPart];
406
+ return [];
407
+ });
408
+ }
409
+
410
+ function isToolPart(part: AssistantContentPart): part is ToolPartContent {
411
+ return part.type === 'tool' && typeof part.data === 'object' && part.data !== null;
412
+ }
413
+
414
+ function messagesToChatCompletions(messages: AgentChatMessage[]): ChatCompletionsMessage[] {
415
+ const items: ChatCompletionsMessage[] = [];
416
+
417
+ for (const msg of messages) {
418
+ if (msg.role === 'user') {
419
+ items.push({role: 'user', content: msg.content});
420
+ continue;
421
+ }
422
+
423
+ const textPieces: string[] = [];
424
+ const toolCalls: ChatCompletionsToolCall[] = [];
425
+ const toolResults: Array<{role: 'tool'; tool_call_id: string; content: string}> = [];
426
+
427
+ for (const part of toContentArray(msg.content)) {
428
+ if (part.type === 'text') {
429
+ const text = (part as TextMessageContent).data.text;
430
+ if (text) textPieces.push(text);
431
+ continue;
432
+ }
433
+ if (!isToolPart(part)) continue;
434
+
435
+ const data = part.data;
436
+ toolCalls.push({
437
+ id: data.toolCallId,
438
+ type: 'function',
439
+ function: {name: data.toolName, arguments: JSON.stringify(data.args ?? {})},
440
+ });
441
+ if (data.result !== undefined) {
442
+ toolResults.push({
443
+ role: 'tool',
444
+ tool_call_id: data.toolCallId,
445
+ content: JSON.stringify(data.result),
446
+ });
447
+ }
448
+ }
449
+
450
+ if (textPieces.length > 0 || toolCalls.length > 0) {
451
+ const content = textPieces.length > 0 ? textPieces.join('\n') : null;
452
+ items.push(
453
+ toolCalls.length > 0
454
+ ? {role: 'assistant', content, tool_calls: toolCalls}
455
+ : {role: 'assistant', content: content ?? ''},
456
+ );
457
+ }
458
+ items.push(...toolResults);
459
+ }
460
+
461
+ return items;
462
+ }
463
+
464
+ function chatCompletionsToAssistantMessage(
465
+ response: ChatCompletionsResponse,
466
+ ): TAssistantMessage<ToolPartContent> {
467
+ const message = response.choices?.[0]?.message ?? {};
468
+ const parts: AssistantContentPart[] = [];
469
+
470
+ if (typeof message.content === 'string' && message.content.trim()) {
471
+ parts.push({type: 'text', data: {text: message.content}});
472
+ }
473
+
474
+ for (const call of message.tool_calls ?? []) {
475
+ if (call.type !== 'function' || !call.function) continue;
476
+
477
+ let parsedArgs: unknown = {};
478
+ let parseError: string | undefined;
479
+ try {
480
+ parsedArgs = call.function.arguments ? JSON.parse(call.function.arguments) : {};
481
+ } catch (err) {
482
+ parseError = err instanceof Error ? err.message : String(err);
483
+ }
484
+
485
+ parts.push({
486
+ type: 'tool',
487
+ id: call.id,
488
+ data: {
489
+ toolName: call.function.name,
490
+ toolCallId: call.id,
491
+ args: parseError ? undefined : parsedArgs,
492
+ status: parseError ? 'error' : 'waitingConfirmation',
493
+ ...(parseError
494
+ ? {
495
+ bodyContent: `Invalid tool arguments JSON: ${parseError}`,
496
+ expandable: true,
497
+ initialExpanded: true,
498
+ }
499
+ : {}),
500
+ },
501
+ });
502
+ }
503
+
504
+ return {id: uuid(), role: 'assistant', content: parts.length === 0 ? '' : parts};
505
+ }
506
+
507
+ const SYSTEM_PROMPT = [
508
+ 'You are a UI assistant integrated into a chat app.',
509
+ 'When a user asks something that maps to a provided tool, CALL THE TOOL instead of prose.',
510
+ 'Examples: "weather in Berlin" -> weather_show; "delete staging db?" -> approval_request.',
511
+ 'Only use plain text when no tool fits.',
512
+ ].join('\n');
513
+
514
+ const CHAT_API_URL =
515
+ (typeof process !== 'undefined' &&
516
+ (process.env as Record<string, string | undefined>).CHAT_API_URL) ||
517
+ '/api/chat';
518
+
519
+ export function AgentChat() {
520
+ const tools = useMemo(() => toolsetToOpenAIDefinitions(toolset), []);
521
+ const [messages, setMessages] = useState<AgentChatMessage[]>([]);
522
+ const [status, setStatus] = useState<ChatStatus>('ready');
523
+ const [errorBanner, setErrorBanner] = useState<string | null>(null);
524
+ const abortRef = useRef<AbortController | null>(null);
525
+
526
+ const sendTurn = useCallback(
527
+ async (nextMessages: AgentChatMessage[]) => {
528
+ setStatus('submitted');
529
+ setErrorBanner(null);
530
+ const controller = new AbortController();
531
+ abortRef.current = controller;
532
+
533
+ try {
534
+ const chatMessages: ChatCompletionsMessage[] = [
535
+ {role: 'system', content: SYSTEM_PROMPT},
536
+ ...messagesToChatCompletions(nextMessages),
537
+ ];
538
+ const res = await fetch(CHAT_API_URL, {
539
+ method: 'POST',
540
+ headers: {'content-type': 'application/json'},
541
+ body: JSON.stringify({messages: chatMessages, tools, tool_choice: 'auto'}),
542
+ signal: controller.signal,
543
+ });
544
+ const body = (await res.json()) as ChatCompletionsResponse;
545
+
546
+ if (!res.ok) {
547
+ const reason =
548
+ (typeof body?.error === 'object' && body.error?.message) ||
549
+ (typeof body?.error === 'string' && body.error) ||
550
+ `Chat API returned ${res.status}`;
551
+ setErrorBanner(String(reason));
552
+ return;
553
+ }
554
+
555
+ const assistant = chatCompletionsToAssistantMessage(body);
556
+ setMessages([...nextMessages, assistant]);
557
+ } catch (err) {
558
+ if ((err as Error)?.name === 'AbortError') return;
559
+ setErrorBanner((err as Error)?.message ?? String(err));
560
+ } finally {
561
+ abortRef.current = null;
562
+ setStatus('ready');
563
+ }
564
+ },
565
+ [tools],
566
+ );
567
+
568
+ const {messageRendererRegistry} = useToolset<ToolPartContent>({
569
+ toolset,
570
+ setMessages,
571
+ });
572
+
573
+ useToolResultContinuation<ToolPartContent>({
574
+ messages,
575
+ onSettled: ({messages: next}) => {
576
+ sendTurn(next).catch((err) => console.warn('sendTurn failed', err));
577
+ },
578
+ });
579
+
580
+ const handleSendMessage = useCallback(
581
+ async (data: TSubmitData) => {
582
+ const userMessage: TUserMessage = {id: uuid(), role: 'user', content: data.content};
583
+ const nextMessages: AgentChatMessage[] = [...messages, userMessage];
584
+ setMessages(nextMessages);
585
+ await sendTurn(nextMessages);
586
+ },
587
+ [messages, sendTurn],
588
+ );
589
+
590
+ const handleCancel = useCallback(async () => {
591
+ abortRef.current?.abort();
592
+ abortRef.current = null;
593
+ setStatus('ready');
594
+ }, []);
595
+
596
+ return (
597
+ <div style={{display: 'flex', flexDirection: 'column', height: '100%'}}>
598
+ {errorBanner && <div role="alert">{errorBanner}</div>}
599
+ <ChatContainer
600
+ messages={messages as TChatMessage[]}
601
+ status={status}
602
+ onSendMessage={handleSendMessage}
603
+ onCancel={handleCancel}
604
+ onSelectChat={() => {}}
605
+ onCreateChat={() => {}}
606
+ messageListConfig={{messageRendererRegistry}}
607
+ />
608
+ </div>
609
+ );
610
+ }
611
+ ```
612
+
613
+ ---
614
+
615
+ ## What the library gives you for free
616
+
617
+ - **Registry** — `useToolset` returns a `MessageRendererRegistry` with the `tool`
618
+ dispatcher wired to your toolset.
619
+ - **`submitResult`** — typed on `ToolComponentProps`; injected by `createToolsetRenderer`.
620
+ - **History merge** — `applyToolResult` on every successful submit.
621
+ - **Continuation hook** — `useToolResultContinuation` observes tool status transitions
622
+ and receives the updated transcript.
623
+ - **Validation** — invalid args or unknown `toolName` fall back to
624
+ `<ToolMessage status="error" />`.
625
+
626
+ What stays in your app: tool definitions (schemas, components, `execute`), the LLM
627
+ adapter, `sendTurn`, and a server route that holds provider credentials.