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