@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.
- package/build/cjs/components/organisms/AssistantMessage/defaultMessageTypeRegistry.js +6 -0
- package/build/cjs/components/organisms/AssistantMessage/defaultMessageTypeRegistry.js.map +1 -1
- package/build/cjs/hooks/index.d.ts +2 -0
- package/build/cjs/hooks/index.js +2 -0
- package/build/cjs/hooks/index.js.map +1 -1
- package/build/cjs/hooks/useToolResultContinuation.d.ts +31 -0
- package/build/cjs/hooks/useToolResultContinuation.js +91 -0
- package/build/cjs/hooks/useToolResultContinuation.js.map +1 -0
- package/build/cjs/hooks/useToolset.d.ts +24 -0
- package/build/cjs/hooks/useToolset.js +26 -0
- package/build/cjs/hooks/useToolset.js.map +1 -0
- package/build/cjs/package.json +1 -1
- package/build/cjs/utils/index.d.ts +1 -0
- package/build/cjs/utils/index.js +1 -0
- package/build/cjs/utils/index.js.map +1 -1
- package/build/cjs/utils/messageTypeRegistry.d.ts +8 -1
- package/build/cjs/utils/messageTypeRegistry.js +14 -2
- package/build/cjs/utils/messageTypeRegistry.js.map +1 -1
- package/build/cjs/utils/toolset/i18n/en.json +3 -0
- package/build/cjs/utils/toolset/i18n/index.d.ts +13 -0
- package/build/cjs/utils/toolset/i18n/index.js +10 -0
- package/build/cjs/utils/toolset/i18n/index.js.map +1 -0
- package/build/cjs/utils/toolset/i18n/ru.json +3 -0
- package/build/cjs/utils/toolset/index.d.ts +142 -0
- package/build/cjs/utils/toolset/index.js +183 -0
- package/build/cjs/utils/toolset/index.js.map +1 -0
- package/build/esm/components/organisms/AssistantMessage/defaultMessageTypeRegistry.js +6 -0
- package/build/esm/components/organisms/AssistantMessage/defaultMessageTypeRegistry.js.map +1 -1
- package/build/esm/hooks/index.d.ts +2 -0
- package/build/esm/hooks/index.js +2 -0
- package/build/esm/hooks/index.js.map +1 -1
- package/build/esm/hooks/useToolResultContinuation.d.ts +31 -0
- package/build/esm/hooks/useToolResultContinuation.js +87 -0
- package/build/esm/hooks/useToolResultContinuation.js.map +1 -0
- package/build/esm/hooks/useToolset.d.ts +24 -0
- package/build/esm/hooks/useToolset.js +23 -0
- package/build/esm/hooks/useToolset.js.map +1 -0
- package/build/esm/package.json +1 -1
- package/build/esm/utils/index.d.ts +1 -0
- package/build/esm/utils/index.js +1 -0
- package/build/esm/utils/index.js.map +1 -1
- package/build/esm/utils/messageTypeRegistry.d.ts +8 -1
- package/build/esm/utils/messageTypeRegistry.js +14 -2
- package/build/esm/utils/messageTypeRegistry.js.map +1 -1
- package/build/esm/utils/toolset/i18n/en.json +3 -0
- package/build/esm/utils/toolset/i18n/index.d.ts +13 -0
- package/build/esm/utils/toolset/i18n/index.js +6 -0
- package/build/esm/utils/toolset/i18n/index.js.map +1 -0
- package/build/esm/utils/toolset/i18n/ru.json +3 -0
- package/build/esm/utils/toolset/index.d.ts +142 -0
- package/build/esm/utils/toolset/index.js +176 -0
- package/build/esm/utils/toolset/index.js.map +1 -0
- package/docs/GENUI.md +627 -0
- package/docs/HOOKS.md +65 -11
- package/llms.txt +2 -1
- 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.
|