@lowdefy/blocks-antd-x 6.1.0 → 7.0.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.
@@ -0,0 +1,933 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import React, { useRef, useMemo, useEffect, useState, useCallback } from 'react';
16
+ import { useChat } from '@ai-sdk/react';
17
+ import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
18
+ import { FileCard, Prompts, Sender } from '@ant-design/x';
19
+ import { Button, Skeleton } from 'antd';
20
+ import { isReserved, setKey, type } from '@lowdefy/helpers';
21
+ import getLegacyObjectUrl from '@lowdefy/blocks-files/utils/getLegacyObjectUrl.js';
22
+ import getUploadPolicy from '@lowdefy/blocks-files/utils/getUploadPolicy.js';
23
+ import uploadFile from '@lowdefy/blocks-files/utils/uploadFile.js';
24
+ import { getFileCardType, getFileCardIcon } from './fileCardUtils.js';
25
+ import DrawerWrapper from './DrawerWrapper.js';
26
+ import createLowdefyChatTransport from './LowdefyChatTransport.js';
27
+ import MessageList from './MessageList.js';
28
+ import replayableMessages from './replayableMessages.js';
29
+ import useAgentEvents, { collectExternalEventIds } from './useAgentEvents.js';
30
+ import WelcomeScreen from './WelcomeScreen.js';
31
+ function AgentChat({ basePath, blockId, components: { Icon, Link }, drawerOpenRequested, events, methods, pageId, pathParams, properties }) {
32
+ const { agentId, urlQuery, sharedState, sharedStateReadOnly, welcome, messageDisplay, sender, conversationId, feedbackValues: storedFeedbackValues, messages: externalMessages, display, drawer: drawerConfig, suggestions, loading } = properties;
33
+ const senderRef = useRef(null);
34
+ const finishMetaRef = useRef(null);
35
+ const fileInputRef = useRef(null);
36
+ const [attachedFiles, setAttachedFiles] = useState([]);
37
+ const [dragOver, setDragOver] = useState(false);
38
+ // Controlled composer value, so a starter prompt (or the setInput method) can
39
+ // fill the box, and a send can leave typed text in place when it is rejected.
40
+ const [inputValue, setInputValue] = useState('');
41
+ // Mirror the operator-evaluated sharedState object into a ref so transport.body()
42
+ // sees the freshest value at send time without re-constructing the transport.
43
+ const sharedStateRef = useRef(null);
44
+ sharedStateRef.current = type.isObject(sharedState) && Object.keys(sharedState).length > 0 ? sharedState : null;
45
+ const sharedStateReadOnlyRef = useRef(false);
46
+ sharedStateReadOnlyRef.current = sharedStateReadOnly === true;
47
+ const attachmentsConfig = sender?.attachments;
48
+ const uploadPolicyRequestId = attachmentsConfig?.uploadPolicyRequestId ?? attachmentsConfig?.s3PostPolicyRequestId;
49
+ const downloadPolicyRequestId = attachmentsConfig?.downloadPolicyRequestId;
50
+ const switchConfigs = sender?.switches ?? [];
51
+ const [headerOpen, setHeaderOpen] = useState(sender?.header?.open ?? true);
52
+ const [switchState, setSwitchState] = useState(()=>{
53
+ const initial = {};
54
+ for (const sw of switchConfigs){
55
+ initial[sw.key] = sw.default ?? false;
56
+ }
57
+ return initial;
58
+ });
59
+ // When no conversationId is supplied, mint a stable session id so every turn
60
+ // (including a welcome prompt sent before the app sets the prop) posts a
61
+ // consistent id. App-supplied ids remain authoritative.
62
+ const mintedIdRef = useRef(null);
63
+ if (!conversationId && !mintedIdRef.current) {
64
+ mintedIdRef.current = crypto.randomUUID();
65
+ }
66
+ const effectiveConversationId = conversationId ?? mintedIdRef.current;
67
+ const urlQueryKey = JSON.stringify(urlQuery ?? null);
68
+ const pathParamsKey = JSON.stringify(pathParams ?? null);
69
+ const transport = useMemo(()=>createLowdefyChatTransport({
70
+ basePath,
71
+ pageId,
72
+ agentId,
73
+ conversationId: effectiveConversationId,
74
+ urlQuery,
75
+ pathParams,
76
+ sharedStateRef,
77
+ sharedStateReadOnlyRef
78
+ }), [
79
+ basePath,
80
+ pageId,
81
+ agentId,
82
+ effectiveConversationId,
83
+ urlQueryKey,
84
+ pathParamsKey
85
+ ]);
86
+ const bubbleListRef = useRef(null);
87
+ // Call ids of client-side tools still running: their parts sit unanswered until
88
+ // addToolOutput, exactly like a cut-off call, and must survive the dead-call filter.
89
+ const pendingToolCallsRef = useRef(new Set());
90
+ // Read by the filter from mount-time method closures, which would see a stale `status`.
91
+ const statusRef = useRef('ready');
92
+ const { messages, sendMessage, status, stop, addToolApprovalResponse, addToolOutput, setMessages, regenerate, clearError } = useChat({
93
+ // Key the Chat instance by conversation: without an id, useChat creates its
94
+ // Chat once per mount and CAPTURES that transport — the transport rebuilt by
95
+ // the useMemo above on a conversationId change is silently ignored, so every
96
+ // send posts under the mount-time conversationId. Continuing a conversation
97
+ // the app selected later then persists the whole transcript under the stale
98
+ // id (a duplicate conversation doc). Keying by id makes useChat swap Chat
99
+ // instances — and adopt the rebuilt transport — when the conversation
100
+ // changes; the clear-on-id-change and external-message-sync effects below
101
+ // compose with the swap unchanged.
102
+ id: effectiveConversationId,
103
+ transport,
104
+ throttle: 50,
105
+ sendAutomaticallyWhen: (args)=>lastAssistantMessageIsCompleteWithToolCalls(args) || lastAssistantMessageIsCompleteWithApprovalResponses(args),
106
+ async onToolCall ({ toolCall }) {
107
+ if (toolCall.dynamic) return;
108
+ if (toolCall.toolName === 'update-page-state') {
109
+ pendingToolCallsRef.current.add(toolCall.toolCallId);
110
+ try {
111
+ const updates = toolCall.input?.updates ?? {};
112
+ // Allowlist writes against the keys currently exposed via sharedState —
113
+ // the tool's description tells the agent "keys must match field names
114
+ // visible in shared state", but we enforce it here so a hallucinated
115
+ // key can't mutate arbitrary page state.
116
+ const allowedKeys = new Set(Object.keys(sharedStateRef.current ?? {}));
117
+ const writable = {};
118
+ const ignored = [];
119
+ for (const [key, value] of Object.entries(updates)){
120
+ if (!allowedKeys.has(key)) {
121
+ ignored.push(key);
122
+ continue;
123
+ }
124
+ // Same policy as a non-allowlisted key: drop it and report it back
125
+ // so the model can self-correct on the next turn.
126
+ if (isReserved(key)) {
127
+ ignored.push(key);
128
+ continue;
129
+ }
130
+ setKey(writable, key, value);
131
+ }
132
+ if (Object.keys(writable).length > 0) {
133
+ await methods.triggerEvent({
134
+ name: '__updatePageState',
135
+ event: writable
136
+ });
137
+ }
138
+ addToolOutput({
139
+ tool: 'update-page-state',
140
+ toolCallId: toolCall.toolCallId,
141
+ output: {
142
+ ok: true,
143
+ written: Object.keys(writable),
144
+ ...ignored.length > 0 ? {
145
+ ignored
146
+ } : {}
147
+ }
148
+ });
149
+ } catch (err) {
150
+ addToolOutput({
151
+ tool: 'update-page-state',
152
+ toolCallId: toolCall.toolCallId,
153
+ state: 'output-error',
154
+ errorText: err.message
155
+ });
156
+ } finally{
157
+ pendingToolCallsRef.current.delete(toolCall.toolCallId);
158
+ }
159
+ }
160
+ },
161
+ onError: (error)=>{
162
+ // A request that fails after the stream's `start` chunk leaves the
163
+ // assistant shell the SDK pushed on that chunk — id set, parts: [] — in
164
+ // the message list. Left there, every later send fails UIMessage
165
+ // validation ("Message must contain at least one part") and the
166
+ // conversation is dead until a reload. Drop empty shells so the user can
167
+ // simply try again; there is nothing in them to show or to send.
168
+ setMessages((prev)=>prev.filter((m)=>m.role !== 'assistant' || (m.parts?.length ?? 0) > 0));
169
+ methods.triggerEvent({
170
+ name: 'onError',
171
+ event: {
172
+ message: error.message
173
+ }
174
+ });
175
+ },
176
+ onFinish: (options)=>{
177
+ finishMetaRef.current = {
178
+ finishReason: options.finishReason,
179
+ isAbort: options.isAbort,
180
+ isDisconnect: options.isDisconnect
181
+ };
182
+ },
183
+ onData: (dataPart)=>{
184
+ methods.triggerEvent({
185
+ name: 'onDataPart',
186
+ event: {
187
+ type: dataPart.type,
188
+ data: dataPart.data,
189
+ id: dataPart.id
190
+ }
191
+ });
192
+ }
193
+ });
194
+ statusRef.current = status;
195
+ // The control is otherwise write-only: it reports a rating and immediately forgets it,
196
+ // so the thumb un-highlights on the next render and the message looks unrated. This holds
197
+ // what was clicked THIS visit; the block still persists nothing itself.
198
+ const [feedbackValues, setFeedbackValues] = useState({});
199
+ // Ratings the app already knows about, keyed by message id — what a reload or a
200
+ // conversation switch would otherwise lose, since the block stores nothing. A click this
201
+ // visit wins over the stored value, so the thumb responds immediately and a rating the
202
+ // user has just withdrawn is not re-lit by a stale prop.
203
+ // Not memoised: the property arrives from operators that rebuild it every render, so a
204
+ // dependency on it would miss every time — the same reason the message sync below
205
+ // compares by count and id rather than by reference.
206
+ const effectiveFeedbackValues = {
207
+ ...storedFeedbackValues ?? {},
208
+ ...feedbackValues
209
+ };
210
+ // Clear messages when conversationId changes so the new conversation starts clean.
211
+ // Developers load saved messages via the messages property if needed.
212
+ const prevConversationIdRef = useRef(effectiveConversationId);
213
+ useEffect(()=>{
214
+ if (effectiveConversationId !== prevConversationIdRef.current) {
215
+ prevConversationIdRef.current = effectiveConversationId;
216
+ setMessages([]);
217
+ // Ratings clicked in the thread being left must not carry over: they are keyed by
218
+ // message id, and the incoming conversation supplies its own through feedbackValues.
219
+ setFeedbackValues({});
220
+ }
221
+ }, [
222
+ effectiveConversationId,
223
+ setMessages
224
+ ]);
225
+ // Abort a reply still streaming into the conversation being left. useChat swaps its
226
+ // Chat instance during the render that changes the id, and the `stop` it returns is
227
+ // the NEW instance's — calling it in the effect above would stop an idle chat and
228
+ // leave the old request running, still firing onDataPart / onToolCall / onFinish
229
+ // into the page (and the server still generating) for a conversation no longer shown.
230
+ // A cleanup keyed only on the id closes over the previous render's `stop`, which is
231
+ // the old instance's. stop() on an idle chat is a no-op.
232
+ useEffect(()=>()=>stop(), [
233
+ effectiveConversationId
234
+ ]); // eslint-disable-line react-hooks/exhaustive-deps
235
+ // Sync external messages when provided — undefined means "not provided" (no sync),
236
+ // null means "clear messages", array means "load these messages".
237
+ // Compare by count + last ID to avoid re-syncing on every Lowdefy re-render
238
+ // (operators like _state create new array references even when data hasn't changed).
239
+ // The conversation id is part of the key: two conversations whose transcripts have the
240
+ // same length and last id (every assistant message an older onFinish hook persisted
241
+ // carries id '', so any two same-length transcripts collide) would otherwise not
242
+ // re-sync on a switch, leaving the new conversation's transcript empty.
243
+ // Seeded with the mount-time id so an undefined `messages` stays "no sync" on mount.
244
+ const prevExternalRef = useRef({
245
+ count: 0,
246
+ lastId: null,
247
+ conversationId: effectiveConversationId
248
+ });
249
+ // Event-dedup ids of externally loaded messages. Restored history must not
250
+ // replay onToolCall / onToolResult / onUserMessage / onTitleGenerated side
251
+ // effects — useAgentEvents suppresses ids in this ref.
252
+ const externalIdsRef = useRef(null);
253
+ useEffect(()=>{
254
+ if (type.isUndefined(externalMessages)) return;
255
+ const msgs = externalMessages ?? [];
256
+ const count = msgs.length;
257
+ const lastId = count > 0 ? msgs[count - 1]?.id : null;
258
+ if (count !== prevExternalRef.current.count || lastId !== prevExternalRef.current.lastId || effectiveConversationId !== prevExternalRef.current.conversationId) {
259
+ prevExternalRef.current = {
260
+ count,
261
+ lastId,
262
+ conversationId: effectiveConversationId
263
+ };
264
+ const replayable = withoutDeadToolCalls(msgs);
265
+ externalIdsRef.current = collectExternalEventIds(replayable);
266
+ setMessages(replayable);
267
+ }
268
+ }, [
269
+ externalMessages,
270
+ effectiveConversationId,
271
+ setMessages
272
+ ]);
273
+ // Register CallMethod methods so YAML actions can control the chat.
274
+ useEffect(()=>{
275
+ methods.registerMethod('regenerate', (args)=>{
276
+ if (sendingRef.current) return;
277
+ regenerateReply(args?.messageId);
278
+ });
279
+ methods.registerMethod('setMessages', (args)=>{
280
+ const msgs = withoutDeadToolCalls(args?.messages ?? []);
281
+ externalIdsRef.current = collectExternalEventIds(msgs);
282
+ setMessages(msgs);
283
+ });
284
+ methods.registerMethod('sendMessage', (args)=>{
285
+ if (args?.text && !sendingRef.current) {
286
+ deliver({
287
+ text: args.text,
288
+ ...args.files ? {
289
+ files: args.files
290
+ } : {},
291
+ ...args.metadata ? {
292
+ metadata: args.metadata
293
+ } : {}
294
+ });
295
+ }
296
+ });
297
+ methods.registerMethod('setInput', (args)=>{
298
+ setInputValue(typeof args?.text === 'string' ? args.text : '');
299
+ });
300
+ methods.registerMethod('clearMessages', ()=>{
301
+ setMessages([]);
302
+ });
303
+ methods.registerMethod('deleteMessage', (args)=>{
304
+ if (args?.messageId) {
305
+ setMessages((prev)=>prev.filter((m)=>m.id !== args.messageId));
306
+ }
307
+ });
308
+ methods.registerMethod('stop', ()=>{
309
+ stop();
310
+ });
311
+ methods.registerMethod('clearError', ()=>{
312
+ clearError();
313
+ });
314
+ methods.registerMethod('scrollToBottom', ()=>{
315
+ bubbleListRef.current?.scrollTo({
316
+ top: 'bottom'
317
+ });
318
+ });
319
+ methods.registerEvent({
320
+ name: '__updatePageState',
321
+ actions: [
322
+ {
323
+ id: 'setState',
324
+ type: 'SetState',
325
+ params: {
326
+ _event: true
327
+ }
328
+ }
329
+ ]
330
+ });
331
+ if (uploadPolicyRequestId) {
332
+ if (attachmentsConfig?.s3PostPolicyRequestId) {
333
+ console.warn('AgentChat attachments property "s3PostPolicyRequestId" is deprecated. Use "uploadPolicyRequestId" instead.');
334
+ }
335
+ methods.registerEvent({
336
+ name: '__getUploadPolicy',
337
+ actions: [
338
+ {
339
+ id: '__getUploadPolicy',
340
+ type: 'Request',
341
+ params: [
342
+ uploadPolicyRequestId
343
+ ]
344
+ }
345
+ ]
346
+ });
347
+ if (downloadPolicyRequestId) {
348
+ methods.registerEvent({
349
+ name: '__getDownloadPolicy',
350
+ actions: [
351
+ {
352
+ id: '__getDownloadPolicy',
353
+ type: 'Request',
354
+ params: [
355
+ downloadPolicyRequestId
356
+ ]
357
+ }
358
+ ]
359
+ });
360
+ }
361
+ }
362
+ }, []);
363
+ useAgentEvents({
364
+ messages,
365
+ status,
366
+ methods,
367
+ finishMetaRef,
368
+ conversationId: effectiveConversationId,
369
+ externalIdsRef
370
+ });
371
+ const isEmpty = messages.length === 0;
372
+ const isBusy = status === 'streaming' || status === 'submitted';
373
+ // The app is still fetching this conversation's transcript (the `loading` property): a
374
+ // skeleton stands in for the message area and the composer is disabled, so nothing can
375
+ // be sent into a conversation whose history has not synced yet.
376
+ const isLoading = loading === true;
377
+ // A send awaits onBeforeSend while useChat still reports `ready`, so every submit during a
378
+ // slow quota check used to send again. The ref refuses re-entry (state is a render behind a
379
+ // double click); the state is what the Sender shows. Every other way of sending (prompts,
380
+ // suggestions, edit, regenerate, the sendMessage and regenerate methods) checks the ref too,
381
+ // or it would go out first and the composer's message straight after it.
382
+ const sendingRef = useRef(false);
383
+ const [sending, setSending] = useState(false);
384
+ // An unanswered tool call left by a reply cut off this session fails validation for the
385
+ // whole history, as one in a loaded history does. A reply still streaming is not cut off,
386
+ // and a client-side tool still running will add its output.
387
+ function withoutDeadToolCalls(list) {
388
+ if (statusRef.current === 'streaming' || statusRef.current === 'submitted') return list;
389
+ return replayableMessages({
390
+ messages: list,
391
+ liveToolCallIds: pendingToolCallsRef.current
392
+ });
393
+ }
394
+ // Every send goes through here. useChat applies the setter synchronously, so sendMessage
395
+ // reads the filtered list.
396
+ function deliver(message) {
397
+ setMessages((prev)=>withoutDeadToolCalls(prev));
398
+ sendMessage(message);
399
+ }
400
+ function regenerateReply(messageId) {
401
+ let kept = [];
402
+ setMessages((prev)=>{
403
+ kept = withoutDeadToolCalls(prev);
404
+ return kept;
405
+ });
406
+ // The reply asked for may have held nothing but a dead call and be gone; regenerate
407
+ // from the end instead of letting useChat throw on the missing id.
408
+ regenerate(kept.some((m)=>m.id === messageId) ? {
409
+ messageId
410
+ } : undefined);
411
+ }
412
+ function fileToContentPart(file) {
413
+ return new Promise((resolve)=>{
414
+ const reader = new FileReader();
415
+ reader.onload = ()=>{
416
+ resolve({
417
+ type: 'file',
418
+ url: reader.result,
419
+ mediaType: file.type,
420
+ filename: file.name
421
+ });
422
+ };
423
+ reader.readAsDataURL(file);
424
+ });
425
+ }
426
+ async function uploadFileToStorage(file) {
427
+ const { name, type: fileType } = file;
428
+ const descriptor = await getUploadPolicy({
429
+ methods,
430
+ file
431
+ });
432
+ await uploadFile({
433
+ descriptor,
434
+ file
435
+ });
436
+ // The model provider fetches this URL, so resolve it through the download
437
+ // request when configured (stable public URL or signed URL). Otherwise
438
+ // fall back to the legacy unsigned object URL from the descriptor.
439
+ let objectUrl;
440
+ if (downloadPolicyRequestId) {
441
+ const response = await methods.triggerEvent({
442
+ name: '__getDownloadPolicy',
443
+ event: {
444
+ file: {
445
+ bucket: descriptor.bucket,
446
+ key: descriptor.key,
447
+ name,
448
+ type: fileType
449
+ }
450
+ }
451
+ });
452
+ if (response.success !== true) {
453
+ throw new Error('Download policy request failed.');
454
+ }
455
+ objectUrl = response.responses.__getDownloadPolicy.response[0];
456
+ } else {
457
+ objectUrl = getLegacyObjectUrl({
458
+ descriptor
459
+ });
460
+ }
461
+ return {
462
+ url: objectUrl,
463
+ mediaType: fileType,
464
+ filename: name,
465
+ key: descriptor.key
466
+ };
467
+ }
468
+ // One intake for every way a file arrives — the paperclip picker, a
469
+ // clipboard paste, a drag-and-drop — so the accept list and size cap apply
470
+ // to all of them, not just the picker's native dialog. Pasted clipboard
471
+ // images all arrive as "image.png" (every browser), and the attached list
472
+ // is keyed by name + size, so those get a unique name.
473
+ function acceptsFile(file) {
474
+ const accept = attachmentsConfig?.accept;
475
+ if (!accept) return true;
476
+ const name = (file.name || '').toLowerCase();
477
+ const mime = (file.type || '').toLowerCase();
478
+ return accept.split(',').map((t)=>t.trim().toLowerCase()).filter(Boolean).some((t)=>{
479
+ if (t.startsWith('.')) return name.endsWith(t);
480
+ if (t.endsWith('/*')) return mime.startsWith(t.slice(0, -1));
481
+ return mime === t;
482
+ });
483
+ }
484
+ function addFiles(incoming, { pasted = false } = {}) {
485
+ const files = Array.from(incoming ?? []).filter((f)=>acceptsFile(f) && !(attachmentsConfig?.maxSize && f.size > attachmentsConfig.maxSize));
486
+ if (files.length === 0) return;
487
+ const named = files.map((f)=>{
488
+ if (!pasted || !/^image\.\w+$/i.test(f.name)) return f;
489
+ const ext = f.name.split('.').pop();
490
+ return new File([
491
+ f
492
+ ], `pasted-${Date.now()}.${ext}`, {
493
+ type: f.type
494
+ });
495
+ });
496
+ setAttachedFiles((prev)=>[
497
+ ...prev,
498
+ ...named
499
+ ]);
500
+ }
501
+ function dragHasFiles(e) {
502
+ return Array.from(e.dataTransfer?.types ?? []).includes('Files');
503
+ }
504
+ async function handleSend(text) {
505
+ if (sendingRef.current) return;
506
+ if (!text.trim() && attachedFiles.length === 0) return;
507
+ sendingRef.current = true;
508
+ setSending(true);
509
+ try {
510
+ await submitMessage(text);
511
+ } finally{
512
+ sendingRef.current = false;
513
+ setSending(false);
514
+ }
515
+ }
516
+ async function submitMessage(text) {
517
+ const filesMeta = attachedFiles.map((file)=>({
518
+ name: file.name,
519
+ size: file.size,
520
+ type: file.type
521
+ }));
522
+ // onBeforeSend — validation and cancellation only
523
+ const response = await methods.triggerEvent({
524
+ name: 'onBeforeSend',
525
+ event: {
526
+ text,
527
+ files: filesMeta,
528
+ messages,
529
+ switches: switchState
530
+ }
531
+ });
532
+ if (response.success === false) return;
533
+ if (attachedFiles.length > 0) {
534
+ const parts = [
535
+ {
536
+ type: 'text',
537
+ text
538
+ }
539
+ ];
540
+ if (uploadPolicyRequestId) {
541
+ try {
542
+ const uploadResults = await Promise.all(attachedFiles.map((file)=>uploadFileToStorage(file)));
543
+ for (const result of uploadResults){
544
+ // The storage key goes under providerMetadata: the AI SDK drops any other extra
545
+ // field on a file part when the server validates the messages, and the key has to
546
+ // reach saved conversations and the tool endpoints that read `_agent: files`.
547
+ parts.push({
548
+ type: 'file',
549
+ url: result.url,
550
+ mediaType: result.mediaType,
551
+ filename: result.filename,
552
+ providerMetadata: {
553
+ lowdefy: {
554
+ key: result.key
555
+ }
556
+ }
557
+ });
558
+ }
559
+ } catch (error) {
560
+ methods.triggerEvent({
561
+ name: 'onError',
562
+ event: {
563
+ message: error.message ?? 'File upload failed.'
564
+ }
565
+ });
566
+ return;
567
+ }
568
+ } else {
569
+ for (const file of attachedFiles){
570
+ const part = await fileToContentPart(file);
571
+ parts.push(part);
572
+ }
573
+ }
574
+ deliver({
575
+ parts
576
+ });
577
+ setAttachedFiles([]);
578
+ } else {
579
+ deliver({
580
+ text
581
+ });
582
+ }
583
+ // Empty the composer here, after the sends and downstream of both the
584
+ // onBeforeSend cancellation return and the upload await, so a send that was
585
+ // rejected or failed to upload leaves the user's text in the box. Resetting
586
+ // from the Sender's onSubmit handler instead would silently lose typed input
587
+ // on every rejected send.
588
+ setInputValue('');
589
+ }
590
+ function handleStop() {
591
+ stop();
592
+ methods.triggerEvent({
593
+ name: 'onStop',
594
+ event: {
595
+ messages
596
+ }
597
+ });
598
+ }
599
+ function handlePromptClick(prompt) {
600
+ if (sendingRef.current) return;
601
+ deliver({
602
+ text: prompt.label
603
+ });
604
+ }
605
+ // A two-track welcome starter fills the composer instead of sending, so a
606
+ // generic shipped default is an editable first draft rather than a message the
607
+ // user never meant to send. Reuses the controlled Sender value.
608
+ function handleWelcomePromptFill(text) {
609
+ setInputValue(typeof text === 'string' ? text : '');
610
+ }
611
+ function handleSuggestionClick(suggestion) {
612
+ if (sendingRef.current) return;
613
+ methods.triggerEvent({
614
+ name: 'onSuggestionClick',
615
+ event: {
616
+ suggestion
617
+ }
618
+ });
619
+ deliver({
620
+ text: suggestion.label
621
+ });
622
+ }
623
+ function handleSwitchChange(key, checked) {
624
+ setSwitchState((prev)=>({
625
+ ...prev,
626
+ [key]: checked
627
+ }));
628
+ methods.triggerEvent({
629
+ name: 'onSwitchChange',
630
+ event: {
631
+ key,
632
+ checked
633
+ }
634
+ });
635
+ }
636
+ const agentSuggestions = useMemo(()=>{
637
+ const lastAssistant = [
638
+ ...messages
639
+ ].reverse().find((m)=>m.role === 'assistant');
640
+ if (!lastAssistant?.parts) return null;
641
+ const suggestionPart = [
642
+ ...lastAssistant.parts
643
+ ].reverse().find((p)=>p.type === 'data-suggestions');
644
+ return suggestionPart?.data?.items ?? null;
645
+ }, [
646
+ messages
647
+ ]);
648
+ const activeSuggestions = agentSuggestions ?? suggestions;
649
+ // Hidden while a composer send awaits onBeforeSend as well as while a reply streams.
650
+ const showSuggestions = !isEmpty && !isLoading && (activeSuggestions?.length ?? 0) > 0 && !isBusy && !sending;
651
+ // A link in an answer was a plain anchor: a full browser navigation out of the
652
+ // conversation, with no way for an app to do anything else with it. Default is only
653
+ // prevented when the app actually handles onLinkClick — otherwise the link keeps
654
+ // navigating, so wiring nothing changes nothing.
655
+ //
656
+ // Modified and non-primary clicks are never intercepted: open-in-new-tab is a
657
+ // reasonable thing to do with a citation, and preventing it would be a regression.
658
+ const interceptLinks = Boolean(events?.onLinkClick);
659
+ // Stable identity: MessageBubble memoises its markdown component map on this, and the map
660
+ // is rebuilt on every streaming chunk otherwise — remounting every link in the answer as
661
+ // it streams.
662
+ const handleLinkClick = useCallback(({ href, text, domEvent })=>{
663
+ if (!interceptLinks || domEvent.defaultPrevented || domEvent.button !== 0 || domEvent.metaKey || domEvent.ctrlKey || domEvent.shiftKey || domEvent.altKey) {
664
+ return;
665
+ }
666
+ // Prevented synchronously: Link checks defaultPrevented immediately after calling
667
+ // this, and an external anchor's default fires before any async work could run.
668
+ domEvent.preventDefault();
669
+ methods.triggerEvent({
670
+ name: 'onLinkClick',
671
+ event: {
672
+ href,
673
+ text
674
+ }
675
+ });
676
+ }, [
677
+ interceptLinks,
678
+ methods
679
+ ]);
680
+ function handleFeedback({ messageId, rating }) {
681
+ setFeedbackValues((prev)=>({
682
+ ...prev,
683
+ [messageId]: rating
684
+ }));
685
+ const message = messages.find((msg)=>msg.id === messageId);
686
+ const messageContent = message?.parts?.filter((part)=>part.type === 'text').map((part)=>part.text).join('') ?? '';
687
+ methods.triggerEvent({
688
+ name: 'onFeedback',
689
+ event: {
690
+ messageId,
691
+ messageContent,
692
+ rating
693
+ }
694
+ });
695
+ }
696
+ function handleRegenerate({ messageId }) {
697
+ if (sendingRef.current) return;
698
+ methods.triggerEvent({
699
+ name: 'onRegenerate',
700
+ event: {
701
+ messageId,
702
+ messages
703
+ }
704
+ });
705
+ regenerateReply(messageId);
706
+ }
707
+ function handleEditMessage({ messageId, originalContent, newContent }) {
708
+ if (sendingRef.current) return;
709
+ methods.triggerEvent({
710
+ name: 'onEditMessage',
711
+ event: {
712
+ messageId,
713
+ originalContent,
714
+ newContent,
715
+ messages
716
+ }
717
+ });
718
+ const messageIndex = messages.findIndex((m)=>m.id === messageId);
719
+ if (messageIndex >= 0) {
720
+ setMessages((prev)=>prev.slice(0, messageIndex));
721
+ deliver({
722
+ text: newContent
723
+ });
724
+ }
725
+ }
726
+ function handleDelete({ messageId }) {
727
+ const message = messages.find((m)=>m.id === messageId);
728
+ const textContent = message?.parts?.filter((p)=>p.type === 'text').map((p)=>p.text).join('') ?? '';
729
+ methods.triggerEvent({
730
+ name: 'onDeleteMessage',
731
+ event: {
732
+ messageId,
733
+ content: textContent,
734
+ messages
735
+ }
736
+ });
737
+ setMessages((prev)=>prev.filter((m)=>m.id !== messageId));
738
+ }
739
+ const chatContent = /*#__PURE__*/ React.createElement("div", {
740
+ id: blockId,
741
+ style: {
742
+ display: 'flex',
743
+ flexDirection: 'column',
744
+ height: display === 'drawer' ? '100%' : properties.height ?? 'calc(100dvh - 170px)',
745
+ maxWidth: properties.maxWidth ?? 800,
746
+ margin: '0 auto',
747
+ width: '100%'
748
+ }
749
+ }, /*#__PURE__*/ React.createElement("div", {
750
+ style: {
751
+ flex: 1,
752
+ minHeight: 0,
753
+ padding: '16px 0'
754
+ }
755
+ }, isLoading ? /*#__PURE__*/ React.createElement("div", {
756
+ className: "agent-chat-loading",
757
+ style: {
758
+ display: 'flex',
759
+ flexDirection: 'column',
760
+ gap: 24,
761
+ padding: '0 16px'
762
+ }
763
+ }, /*#__PURE__*/ React.createElement(Skeleton, {
764
+ active: true,
765
+ title: false,
766
+ paragraph: {
767
+ rows: 2,
768
+ width: [
769
+ '60%',
770
+ '40%'
771
+ ]
772
+ }
773
+ }), /*#__PURE__*/ React.createElement(Skeleton, {
774
+ active: true,
775
+ title: false,
776
+ paragraph: {
777
+ rows: 3,
778
+ width: [
779
+ '90%',
780
+ '75%',
781
+ '50%'
782
+ ]
783
+ }
784
+ })) : isEmpty && !welcome?.tracks ? /*#__PURE__*/ React.createElement(WelcomeScreen, {
785
+ config: welcome,
786
+ onPromptClick: handlePromptClick
787
+ }) : /*#__PURE__*/ React.createElement(MessageList, {
788
+ ref: bubbleListRef,
789
+ Icon: Icon,
790
+ messages: messages,
791
+ isStreaming: isBusy,
792
+ config: messageDisplay,
793
+ welcome: welcome,
794
+ onWelcomePromptFill: handleWelcomePromptFill,
795
+ addToolApprovalResponse: addToolApprovalResponse,
796
+ onFeedback: handleFeedback,
797
+ onLinkClick: handleLinkClick,
798
+ Link: Link,
799
+ feedbackValues: effectiveFeedbackValues,
800
+ onRegenerate: handleRegenerate,
801
+ onDelete: handleDelete,
802
+ onEditMessage: handleEditMessage,
803
+ translate: methods.translate
804
+ })), showSuggestions && /*#__PURE__*/ React.createElement("div", {
805
+ style: {
806
+ padding: '0 16px 8px'
807
+ }
808
+ }, /*#__PURE__*/ React.createElement(Prompts, {
809
+ items: activeSuggestions.map((s, i)=>({
810
+ key: s.key ?? `suggestion-${i}`,
811
+ label: s.label,
812
+ description: s.description
813
+ })),
814
+ onItemClick: ({ data })=>handleSuggestionClick(data),
815
+ wrap: true
816
+ })), /*#__PURE__*/ React.createElement("div", {
817
+ style: {
818
+ padding: '8px 16px 24px',
819
+ borderRadius: 12,
820
+ outline: dragOver ? '2px dashed currentColor' : 'none',
821
+ outlineOffset: -6,
822
+ transition: 'outline-color 120ms'
823
+ },
824
+ // Drag-in lands in the same intake as the picker and paste. Only
825
+ // react to drags carrying files, so text selections dragged across
826
+ // the composer are left to the textarea.
827
+ onDragOver: attachmentsConfig?.enabled ? (e)=>{
828
+ if (!dragHasFiles(e)) return;
829
+ e.preventDefault();
830
+ if (!dragOver) setDragOver(true);
831
+ } : undefined,
832
+ onDragLeave: attachmentsConfig?.enabled ? ()=>setDragOver(false) : undefined,
833
+ onDrop: attachmentsConfig?.enabled ? (e)=>{
834
+ if (!dragHasFiles(e)) return;
835
+ e.preventDefault();
836
+ setDragOver(false);
837
+ addFiles(e.dataTransfer.files);
838
+ } : undefined
839
+ }, attachmentsConfig?.enabled && attachedFiles.length > 0 && /*#__PURE__*/ React.createElement(FileCard.List, {
840
+ style: {
841
+ marginBottom: 4
842
+ },
843
+ items: attachedFiles.map((file, i)=>{
844
+ const cardType = getFileCardType(file.type);
845
+ return {
846
+ key: `${i}`,
847
+ name: file.name,
848
+ byte: file.size,
849
+ type: cardType,
850
+ icon: getFileCardIcon(file.type, file.name),
851
+ src: cardType === 'image' ? URL.createObjectURL(file) : undefined
852
+ };
853
+ }),
854
+ removable: true,
855
+ onRemove: (item)=>{
856
+ setAttachedFiles((prev)=>{
857
+ const idx = prev.findIndex((f)=>f.name === item.name && f.size === item.byte);
858
+ return idx !== -1 ? prev.filter((_, j)=>j !== idx) : prev;
859
+ });
860
+ },
861
+ overflow: "wrap",
862
+ size: "small"
863
+ }), attachmentsConfig?.enabled && /*#__PURE__*/ React.createElement("input", {
864
+ ref: fileInputRef,
865
+ type: "file",
866
+ style: {
867
+ display: 'none'
868
+ },
869
+ accept: attachmentsConfig.accept,
870
+ multiple: true,
871
+ onChange: (e)=>{
872
+ addFiles(e.target.files);
873
+ e.target.value = '';
874
+ }
875
+ }), /*#__PURE__*/ React.createElement(Sender, {
876
+ ref: senderRef,
877
+ value: inputValue,
878
+ onChange: setInputValue,
879
+ placeholder: sender?.placeholder ?? methods.translate('agent.sender.placeholder'),
880
+ submitType: sender?.submitType ?? 'enter',
881
+ allowSpeech: sender?.allowSpeech ?? false,
882
+ onSubmit: handleSend,
883
+ // Clipboard paste (a screenshot, a copied file) attaches like the
884
+ // paperclip does.
885
+ onPasteFile: attachmentsConfig?.enabled ? (files)=>addFiles(files, {
886
+ pasted: true
887
+ }) : undefined,
888
+ // Not during the onBeforeSend wait: nothing is streaming, so onStop would be false.
889
+ onCancel: isBusy ? handleStop : undefined,
890
+ loading: isBusy || sending,
891
+ disabled: isLoading,
892
+ styles: sender?.styles,
893
+ classNames: sender?.classNames,
894
+ prefix: attachmentsConfig?.enabled ? /*#__PURE__*/ React.createElement(Button, {
895
+ type: "text",
896
+ icon: /*#__PURE__*/ React.createElement(Icon, {
897
+ blockId: `${blockId}_attach_icon`,
898
+ properties: {
899
+ name: 'attach',
900
+ title: ''
901
+ }
902
+ }),
903
+ onClick: ()=>fileInputRef.current?.click()
904
+ }) : undefined,
905
+ header: sender?.header ? /*#__PURE__*/ React.createElement(Sender.Header, {
906
+ title: sender.header.title,
907
+ closable: sender.header.closable ?? true,
908
+ open: headerOpen,
909
+ onOpenChange: setHeaderOpen
910
+ }, sender.header.content) : undefined,
911
+ footer: switchConfigs.length > 0 ? switchConfigs.map((sw)=>/*#__PURE__*/ React.createElement(Sender.Switch, {
912
+ key: sw.key,
913
+ value: switchState[sw.key] ?? false,
914
+ onChange: (checked)=>handleSwitchChange(sw.key, checked),
915
+ icon: sw.icon ? /*#__PURE__*/ React.createElement(Icon, {
916
+ blockId: `${blockId}_switch_${sw.key}_icon`,
917
+ events: {},
918
+ properties: sw.icon
919
+ }) : undefined
920
+ }, sw.label)) : undefined
921
+ })));
922
+ if (display === 'drawer') {
923
+ // The launcher was clicked while this implementation was still loading.
924
+ return /*#__PURE__*/ React.createElement(DrawerWrapper, {
925
+ blockId: blockId,
926
+ config: drawerConfig,
927
+ defaultOpen: drawerOpenRequested === true,
928
+ Icon: Icon
929
+ }, chatContent);
930
+ }
931
+ return chatContent;
932
+ }
933
+ export default AgentChat;