@lowdefy/blocks-antd-x 5.5.1 → 6.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.
@@ -12,30 +12,39 @@
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 } from 'react';
15
+ */ import React, { useRef, useMemo, useEffect, useState, useCallback } from 'react';
16
16
  import { useChat } from '@ai-sdk/react';
17
17
  import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
18
18
  import { FileCard, Prompts, Sender } from '@ant-design/x';
19
19
  import { Button } from 'antd';
20
20
  import { PaperClipOutlined } from '@ant-design/icons';
21
- import { type } from '@lowdefy/helpers';
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';
22
25
  import { getFileCardType, getFileCardIcon } from './fileCardUtils.js';
23
26
  import DrawerWrapper from './DrawerWrapper.js';
24
27
  import createLowdefyChatTransport from './LowdefyChatTransport.js';
25
28
  import MessageList from './MessageList.js';
26
29
  import useAgentEvents, { collectExternalEventIds } from './useAgentEvents.js';
27
30
  import WelcomeScreen from './WelcomeScreen.js';
28
- function AgentChat({ blockId, components: { Icon }, methods, pageId, properties }) {
29
- const { agentId, urlQuery, sharedState, welcome, messageDisplay, sender, conversationId, messages: externalMessages, display, drawer: drawerConfig, suggestions } = properties;
31
+ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageId, properties }) {
32
+ const { agentId, urlQuery, sharedState, welcome, messageDisplay, sender, conversationId, feedbackValues: storedFeedbackValues, messages: externalMessages, display, drawer: drawerConfig, suggestions } = properties;
30
33
  const senderRef = useRef(null);
31
34
  const finishMetaRef = useRef(null);
32
35
  const fileInputRef = useRef(null);
33
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('');
34
41
  // Mirror the operator-evaluated sharedState object into a ref so transport.body()
35
42
  // sees the freshest value at send time without re-constructing the transport.
36
43
  const sharedStateRef = useRef(null);
37
44
  sharedStateRef.current = type.isObject(sharedState) && Object.keys(sharedState).length > 0 ? sharedState : null;
38
45
  const attachmentsConfig = sender?.attachments;
46
+ const uploadPolicyRequestId = attachmentsConfig?.uploadPolicyRequestId ?? attachmentsConfig?.s3PostPolicyRequestId;
47
+ const downloadPolicyRequestId = attachmentsConfig?.downloadPolicyRequestId;
39
48
  const switchConfigs = sender?.switches ?? [];
40
49
  const [headerOpen, setHeaderOpen] = useState(sender?.header?.open ?? true);
41
50
  const [switchState, setSwitchState] = useState(()=>{
@@ -68,6 +77,16 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
68
77
  ]);
69
78
  const bubbleListRef = useRef(null);
70
79
  const { messages, sendMessage, status, stop, addToolApprovalResponse, addToolOutput, setMessages, regenerate, clearError } = useChat({
80
+ // Key the Chat instance by conversation: without an id, useChat creates its
81
+ // Chat once per mount and CAPTURES that transport — the transport rebuilt by
82
+ // the useMemo above on a conversationId change is silently ignored, so every
83
+ // send posts under the mount-time conversationId. Continuing a conversation
84
+ // the app selected later then persists the whole transcript under the stale
85
+ // id (a duplicate conversation doc). Keying by id makes useChat swap Chat
86
+ // instances — and adopt the rebuilt transport — when the conversation
87
+ // changes; the clear-on-id-change and external-message-sync effects below
88
+ // compose with the swap unchanged.
89
+ id: effectiveConversationId,
71
90
  transport,
72
91
  experimental_throttle: 50,
73
92
  sendAutomaticallyWhen: (args)=>lastAssistantMessageIsCompleteWithToolCalls(args) || lastAssistantMessageIsCompleteWithApprovalResponses(args),
@@ -84,8 +103,17 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
84
103
  const writable = {};
85
104
  const ignored = [];
86
105
  for (const [key, value] of Object.entries(updates)){
87
- if (allowedKeys.has(key)) writable[key] = value;
88
- else ignored.push(key);
106
+ if (!allowedKeys.has(key)) {
107
+ ignored.push(key);
108
+ continue;
109
+ }
110
+ // Same policy as a non-allowlisted key: drop it and report it back
111
+ // so the model can self-correct on the next turn.
112
+ if (isReserved(key)) {
113
+ ignored.push(key);
114
+ continue;
115
+ }
116
+ setKey(writable, key, value);
89
117
  }
90
118
  if (Object.keys(writable).length > 0) {
91
119
  await methods.triggerEvent({
@@ -115,6 +143,13 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
115
143
  }
116
144
  },
117
145
  onError: (error)=>{
146
+ // A request that fails after the stream's `start` chunk leaves the
147
+ // assistant shell the SDK pushed on that chunk — id set, parts: [] — in
148
+ // the message list. Left there, every later send fails UIMessage
149
+ // validation ("Message must contain at least one part") and the
150
+ // conversation is dead until a reload. Drop empty shells so the user can
151
+ // simply try again; there is nothing in them to show or to send.
152
+ setMessages((prev)=>prev.filter((m)=>m.role !== 'assistant' || (m.parts?.length ?? 0) > 0));
118
153
  methods.triggerEvent({
119
154
  name: 'onError',
120
155
  event: {
@@ -140,6 +175,21 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
140
175
  });
141
176
  }
142
177
  });
178
+ // The control is otherwise write-only: it reports a rating and immediately forgets it,
179
+ // so the thumb un-highlights on the next render and the message looks unrated. This holds
180
+ // what was clicked THIS visit; the block still persists nothing itself.
181
+ const [feedbackValues, setFeedbackValues] = useState({});
182
+ // Ratings the app already knows about, keyed by message id — what a reload or a
183
+ // conversation switch would otherwise lose, since the block stores nothing. A click this
184
+ // visit wins over the stored value, so the thumb responds immediately and a rating the
185
+ // user has just withdrawn is not re-lit by a stale prop.
186
+ // Not memoised: the property arrives from operators that rebuild it every render, so a
187
+ // dependency on it would miss every time — the same reason the message sync below
188
+ // compares by count and id rather than by reference.
189
+ const effectiveFeedbackValues = {
190
+ ...storedFeedbackValues ?? {},
191
+ ...feedbackValues
192
+ };
143
193
  // Clear messages when conversationId changes so the new conversation starts clean.
144
194
  // Developers load saved messages via the messages property if needed.
145
195
  const prevConversationIdRef = useRef(effectiveConversationId);
@@ -147,6 +197,9 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
147
197
  if (effectiveConversationId !== prevConversationIdRef.current) {
148
198
  prevConversationIdRef.current = effectiveConversationId;
149
199
  setMessages([]);
200
+ // Ratings clicked in the thread being left must not carry over: they are keyed by
201
+ // message id, and the incoming conversation supplies its own through feedbackValues.
202
+ setFeedbackValues({});
150
203
  }
151
204
  }, [
152
205
  effectiveConversationId,
@@ -198,7 +251,7 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
198
251
  sendMessage({
199
252
  text: args.text,
200
253
  ...args.files ? {
201
- experimental_attachments: args.files
254
+ files: args.files
202
255
  } : {},
203
256
  ...args.metadata ? {
204
257
  metadata: args.metadata
@@ -206,6 +259,9 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
206
259
  });
207
260
  }
208
261
  });
262
+ methods.registerMethod('setInput', (args)=>{
263
+ setInputValue(typeof args?.text === 'string' ? args.text : '');
264
+ });
209
265
  methods.registerMethod('clearMessages', ()=>{
210
266
  setMessages([]);
211
267
  });
@@ -237,19 +293,36 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
237
293
  }
238
294
  ]
239
295
  });
240
- if (attachmentsConfig?.s3PostPolicyRequestId) {
296
+ if (uploadPolicyRequestId) {
297
+ if (attachmentsConfig?.s3PostPolicyRequestId) {
298
+ console.warn('AgentChat attachments property "s3PostPolicyRequestId" is deprecated. Use "uploadPolicyRequestId" instead.');
299
+ }
241
300
  methods.registerEvent({
242
- name: '__getS3PostPolicy',
301
+ name: '__getUploadPolicy',
243
302
  actions: [
244
303
  {
245
- id: '__getS3PostPolicy',
304
+ id: '__getUploadPolicy',
246
305
  type: 'Request',
247
306
  params: [
248
- attachmentsConfig.s3PostPolicyRequestId
307
+ uploadPolicyRequestId
249
308
  ]
250
309
  }
251
310
  ]
252
311
  });
312
+ if (downloadPolicyRequestId) {
313
+ methods.registerEvent({
314
+ name: '__getDownloadPolicy',
315
+ actions: [
316
+ {
317
+ id: '__getDownloadPolicy',
318
+ type: 'Request',
319
+ params: [
320
+ downloadPolicyRequestId
321
+ ]
322
+ }
323
+ ]
324
+ });
325
+ }
253
326
  }
254
327
  }, []);
255
328
  useAgentEvents({
@@ -276,48 +349,83 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
276
349
  reader.readAsDataURL(file);
277
350
  });
278
351
  }
279
- async function uploadFileToS3(file) {
280
- const { name, size, type: fileType } = file;
281
- const s3PostPolicyResponse = await methods.triggerEvent({
282
- name: '__getS3PostPolicy',
283
- event: {
284
- file: {
285
- name,
286
- size,
287
- type: fileType
288
- }
289
- }
352
+ async function uploadFileToStorage(file) {
353
+ const { name, type: fileType } = file;
354
+ const descriptor = await getUploadPolicy({
355
+ methods,
356
+ file
290
357
  });
291
- if (s3PostPolicyResponse.success !== true) {
292
- throw new Error('S3 post policy request failed.');
293
- }
294
- const { url, fields = {} } = s3PostPolicyResponse.responses.__getS3PostPolicy.response[0];
295
- const { key } = fields;
296
- const formData = new FormData();
297
- Object.keys(fields).forEach((field)=>{
298
- formData.append(field, fields[field]);
358
+ await uploadFile({
359
+ descriptor,
360
+ file
299
361
  });
300
- formData.append('file', file);
301
- await new Promise((resolve, reject)=>{
302
- const xhr = new XMLHttpRequest();
303
- xhr.addEventListener('load', ()=>{
304
- if (xhr.status >= 200 && xhr.status < 300) {
305
- resolve();
306
- } else {
307
- reject(new Error(`S3 upload failed with status ${xhr.status}`));
362
+ // The model provider fetches this URL, so resolve it through the download
363
+ // request when configured (stable public URL or signed URL). Otherwise
364
+ // fall back to the legacy unsigned object URL from the descriptor.
365
+ let objectUrl;
366
+ if (downloadPolicyRequestId) {
367
+ const response = await methods.triggerEvent({
368
+ name: '__getDownloadPolicy',
369
+ event: {
370
+ file: {
371
+ bucket: descriptor.bucket,
372
+ key: descriptor.key,
373
+ name,
374
+ type: fileType
375
+ }
308
376
  }
309
377
  });
310
- xhr.addEventListener('error', ()=>reject(new Error('S3 upload network error')));
311
- xhr.open('POST', url);
312
- xhr.send(formData);
313
- });
314
- const objectUrl = url.endsWith('/') ? `${url}${key}` : `${url}/${key}`;
378
+ if (response.success !== true) {
379
+ throw new Error('Download policy request failed.');
380
+ }
381
+ objectUrl = response.responses.__getDownloadPolicy.response[0];
382
+ } else {
383
+ objectUrl = getLegacyObjectUrl({
384
+ descriptor
385
+ });
386
+ }
315
387
  return {
316
388
  url: objectUrl,
317
389
  mediaType: fileType,
318
390
  filename: name
319
391
  };
320
392
  }
393
+ // One intake for every way a file arrives — the paperclip picker, a
394
+ // clipboard paste, a drag-and-drop — so the accept list and size cap apply
395
+ // to all of them, not just the picker's native dialog. Pasted clipboard
396
+ // images all arrive as "image.png" (every browser), and the attached list
397
+ // is keyed by name + size, so those get a unique name.
398
+ function acceptsFile(file) {
399
+ const accept = attachmentsConfig?.accept;
400
+ if (!accept) return true;
401
+ const name = (file.name || '').toLowerCase();
402
+ const mime = (file.type || '').toLowerCase();
403
+ return accept.split(',').map((t)=>t.trim().toLowerCase()).filter(Boolean).some((t)=>{
404
+ if (t.startsWith('.')) return name.endsWith(t);
405
+ if (t.endsWith('/*')) return mime.startsWith(t.slice(0, -1));
406
+ return mime === t;
407
+ });
408
+ }
409
+ function addFiles(incoming, { pasted = false } = {}) {
410
+ const files = Array.from(incoming ?? []).filter((f)=>acceptsFile(f) && !(attachmentsConfig?.maxSize && f.size > attachmentsConfig.maxSize));
411
+ if (files.length === 0) return;
412
+ const named = files.map((f)=>{
413
+ if (!pasted || !/^image\.\w+$/i.test(f.name)) return f;
414
+ const ext = f.name.split('.').pop();
415
+ return new File([
416
+ f
417
+ ], `pasted-${Date.now()}.${ext}`, {
418
+ type: f.type
419
+ });
420
+ });
421
+ setAttachedFiles((prev)=>[
422
+ ...prev,
423
+ ...named
424
+ ]);
425
+ }
426
+ function dragHasFiles(e) {
427
+ return Array.from(e.dataTransfer?.types ?? []).includes('Files');
428
+ }
321
429
  async function handleSend(text) {
322
430
  if (!text.trim() && attachedFiles.length === 0) return;
323
431
  const filesMeta = attachedFiles.map((file)=>({
@@ -343,9 +451,9 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
343
451
  text
344
452
  }
345
453
  ];
346
- if (attachmentsConfig?.s3PostPolicyRequestId) {
454
+ if (uploadPolicyRequestId) {
347
455
  try {
348
- const uploadResults = await Promise.all(attachedFiles.map((file)=>uploadFileToS3(file)));
456
+ const uploadResults = await Promise.all(attachedFiles.map((file)=>uploadFileToStorage(file)));
349
457
  for (const result of uploadResults){
350
458
  parts.push({
351
459
  type: 'file',
@@ -358,7 +466,7 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
358
466
  methods.triggerEvent({
359
467
  name: 'onError',
360
468
  event: {
361
- message: error.message ?? 'File upload to S3 failed.'
469
+ message: error.message ?? 'File upload failed.'
362
470
  }
363
471
  });
364
472
  return;
@@ -378,7 +486,12 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
378
486
  text
379
487
  });
380
488
  }
381
- senderRef.current?.clear();
489
+ // Empty the composer here, after the sends and downstream of both the
490
+ // onBeforeSend cancellation return and the upload await, so a send that was
491
+ // rejected or failed to upload leaves the user's text in the box. Resetting
492
+ // from the Sender's onSubmit handler instead would silently lose typed input
493
+ // on every rejected send.
494
+ setInputValue('');
382
495
  }
383
496
  function handleStop() {
384
497
  stop();
@@ -394,6 +507,12 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
394
507
  text: prompt.label
395
508
  });
396
509
  }
510
+ // A two-track welcome starter fills the composer instead of sending, so a
511
+ // generic shipped default is an editable first draft rather than a message the
512
+ // user never meant to send. Reuses the controlled Sender value.
513
+ function handleWelcomePromptFill(text) {
514
+ setInputValue(typeof text === 'string' ? text : '');
515
+ }
397
516
  function handleSuggestionClick(suggestion) {
398
517
  methods.triggerEvent({
399
518
  name: 'onSuggestionClick',
@@ -431,7 +550,40 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
431
550
  messages
432
551
  ]);
433
552
  const activeSuggestions = agentSuggestions ?? suggestions;
553
+ // A link in an answer was a plain anchor: a full browser navigation out of the
554
+ // conversation, with no way for an app to do anything else with it. Default is only
555
+ // prevented when the app actually handles onLinkClick — otherwise the link keeps
556
+ // navigating, so wiring nothing changes nothing.
557
+ //
558
+ // Modified and non-primary clicks are never intercepted: open-in-new-tab is a
559
+ // reasonable thing to do with a citation, and preventing it would be a regression.
560
+ const interceptLinks = Boolean(events?.onLinkClick);
561
+ // Stable identity: MessageBubble memoises its markdown component map on this, and the map
562
+ // is rebuilt on every streaming chunk otherwise — remounting every link in the answer as
563
+ // it streams.
564
+ const handleLinkClick = useCallback(({ href, text, domEvent })=>{
565
+ if (!interceptLinks || domEvent.defaultPrevented || domEvent.button !== 0 || domEvent.metaKey || domEvent.ctrlKey || domEvent.shiftKey || domEvent.altKey) {
566
+ return;
567
+ }
568
+ // Prevented synchronously: Link checks defaultPrevented immediately after calling
569
+ // this, and an external anchor's default fires before any async work could run.
570
+ domEvent.preventDefault();
571
+ methods.triggerEvent({
572
+ name: 'onLinkClick',
573
+ event: {
574
+ href,
575
+ text
576
+ }
577
+ });
578
+ }, [
579
+ interceptLinks,
580
+ methods
581
+ ]);
434
582
  function handleFeedback({ messageId, rating }) {
583
+ setFeedbackValues((prev)=>({
584
+ ...prev,
585
+ [messageId]: rating
586
+ }));
435
587
  const message = messages.find((msg)=>msg.id === messageId);
436
588
  const messageContent = message?.parts?.filter((part)=>part.type === 'text').map((part)=>part.text).join('') ?? '';
437
589
  methods.triggerEvent({
@@ -502,7 +654,7 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
502
654
  minHeight: 0,
503
655
  padding: '16px 0'
504
656
  }
505
- }, isEmpty ? /*#__PURE__*/ React.createElement(WelcomeScreen, {
657
+ }, isEmpty && !welcome?.tracks ? /*#__PURE__*/ React.createElement(WelcomeScreen, {
506
658
  config: welcome,
507
659
  onPromptClick: handlePromptClick
508
660
  }) : /*#__PURE__*/ React.createElement(MessageList, {
@@ -510,8 +662,13 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
510
662
  messages: messages,
511
663
  isStreaming: isBusy,
512
664
  config: messageDisplay,
665
+ welcome: welcome,
666
+ onWelcomePromptFill: handleWelcomePromptFill,
513
667
  addToolApprovalResponse: addToolApprovalResponse,
514
668
  onFeedback: handleFeedback,
669
+ onLinkClick: handleLinkClick,
670
+ Link: Link,
671
+ feedbackValues: effectiveFeedbackValues,
515
672
  onRegenerate: handleRegenerate,
516
673
  onDelete: handleDelete,
517
674
  onEditMessage: handleEditMessage,
@@ -530,8 +687,27 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
530
687
  wrap: true
531
688
  })), /*#__PURE__*/ React.createElement("div", {
532
689
  style: {
533
- padding: '8px 16px 24px'
534
- }
690
+ padding: '8px 16px 24px',
691
+ borderRadius: 12,
692
+ outline: dragOver ? '2px dashed currentColor' : 'none',
693
+ outlineOffset: -6,
694
+ transition: 'outline-color 120ms'
695
+ },
696
+ // Drag-in lands in the same intake as the picker and paste. Only
697
+ // react to drags carrying files, so text selections dragged across
698
+ // the composer are left to the textarea.
699
+ onDragOver: attachmentsConfig?.enabled ? (e)=>{
700
+ if (!dragHasFiles(e)) return;
701
+ e.preventDefault();
702
+ if (!dragOver) setDragOver(true);
703
+ } : undefined,
704
+ onDragLeave: attachmentsConfig?.enabled ? ()=>setDragOver(false) : undefined,
705
+ onDrop: attachmentsConfig?.enabled ? (e)=>{
706
+ if (!dragHasFiles(e)) return;
707
+ e.preventDefault();
708
+ setDragOver(false);
709
+ addFiles(e.dataTransfer.files);
710
+ } : undefined
535
711
  }, attachmentsConfig?.enabled && attachedFiles.length > 0 && /*#__PURE__*/ React.createElement(FileCard.List, {
536
712
  style: {
537
713
  marginBottom: 4
@@ -565,20 +741,22 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
565
741
  accept: attachmentsConfig.accept,
566
742
  multiple: true,
567
743
  onChange: (e)=>{
568
- const files = Array.from(e.target.files);
569
- const valid = files.filter((f)=>!(attachmentsConfig.maxSize && f.size > attachmentsConfig.maxSize));
570
- setAttachedFiles((prev)=>[
571
- ...prev,
572
- ...valid
573
- ]);
744
+ addFiles(e.target.files);
574
745
  e.target.value = '';
575
746
  }
576
747
  }), /*#__PURE__*/ React.createElement(Sender, {
577
748
  ref: senderRef,
749
+ value: inputValue,
750
+ onChange: setInputValue,
578
751
  placeholder: sender?.placeholder ?? methods.translate('agent.sender.placeholder'),
579
752
  submitType: sender?.submitType ?? 'enter',
580
753
  allowSpeech: sender?.allowSpeech ?? false,
581
754
  onSubmit: handleSend,
755
+ // Clipboard paste (a screenshot, a copied file) attaches like the
756
+ // paperclip does.
757
+ onPasteFile: attachmentsConfig?.enabled ? (files)=>addFiles(files, {
758
+ pasted: true
759
+ }) : undefined,
582
760
  onCancel: handleStop,
583
761
  loading: isBusy,
584
762
  prefix: attachmentsConfig?.enabled ? /*#__PURE__*/ React.createElement(Button, {
@@ -612,7 +790,6 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
612
790
  }
613
791
  AgentChat.meta = {
614
792
  category: 'display',
615
- icons: [],
616
- styles: []
793
+ icons: []
617
794
  };
618
795
  export default AgentChat;
@@ -14,7 +14,9 @@
14
14
  limitations under the License.
15
15
  */ import { DefaultChatTransport } from 'ai';
16
16
  function createLowdefyChatTransport({ pageId, agentId, conversationId, urlQuery, sharedStateRef }) {
17
- const base = `/api/agent/${pageId}/${agentId}`;
17
+ // The server route takes the last path segment as the agentId — module-scoped
18
+ // agent ids contain '/', so encode the agentId into a single segment.
19
+ const base = `/api/agent/${pageId}/${encodeURIComponent(agentId)}`;
18
20
  const api = conversationId ? `${base}?conversationId=${encodeURIComponent(conversationId)}` : base;
19
21
  return new DefaultChatTransport({
20
22
  api,
@@ -101,6 +101,52 @@ function resolveToolResultMode(toolResultDisplay, toolName) {
101
101
  }
102
102
  return 'summary';
103
103
  }
104
+ // Plain link text arrives as a string, but any inline formatting makes it an element tree —
105
+ // so it is flattened rather than reported as absent.
106
+ function linkText(children) {
107
+ if (typeof children === 'string') return children;
108
+ if (typeof children === 'number') return String(children);
109
+ if (Array.isArray(children)) return children.map(linkText).join('');
110
+ if (/*#__PURE__*/ React.isValidElement(children)) return linkText(children.props?.children);
111
+ return '';
112
+ }
113
+ // An in-app path renders through the app's Link, so following a citation is a client-side
114
+ // route rather than a reload that drops the conversation. Anything with a scheme opens in a
115
+ // new tab for the same reason. Both still report the click, so an app that handles
116
+ // onLinkClick can show the target in place instead.
117
+ function MarkdownLink({ Link, onLinkClick }) {
118
+ return function MarkdownAnchor({ href, children, ...props }) {
119
+ if (!href) return /*#__PURE__*/ React.createElement("span", props, children);
120
+ const text = linkText(children);
121
+ const handleClick = (domEvent)=>onLinkClick?.({
122
+ href,
123
+ text,
124
+ domEvent
125
+ });
126
+ const external = /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
127
+ // A fragment cannot be expressed as a pageId link, so it keeps the anchor.
128
+ const inApp = !external && href.startsWith('/') && !href.includes('#');
129
+ if (!inApp || !Link) {
130
+ return /*#__PURE__*/ React.createElement("a", {
131
+ ...props,
132
+ href: href,
133
+ target: external ? '_blank' : undefined,
134
+ rel: external ? 'noopener noreferrer' : undefined,
135
+ onClick: handleClick
136
+ }, children);
137
+ }
138
+ // `pageId`, not `href`: Link renders a plain anchor for href and url — its new-origin form —
139
+ // and only routes client-side for a pageId. Passing the path as href therefore went through
140
+ // Link and still reloaded the page, dropping the conversation this exists to preserve.
141
+ const [pathname, search = ''] = href.slice(1).split('?');
142
+ return /*#__PURE__*/ React.createElement(Link, {
143
+ ...props,
144
+ pageId: pathname,
145
+ urlQuery: Object.fromEntries(new URLSearchParams(search)),
146
+ onClick: handleClick
147
+ }, children);
148
+ };
149
+ }
104
150
  function normalizeActions(actions) {
105
151
  if (Array.isArray(actions)) {
106
152
  const obj = {};
@@ -111,7 +157,7 @@ function normalizeActions(actions) {
111
157
  }
112
158
  return actions ?? {};
113
159
  }
114
- function BubbleActions({ actions, textContent, messageId, onFeedback, onRegenerate, onDelete, translate }) {
160
+ function BubbleActions({ actions, textContent, messageId, onFeedback, feedbackValue, onRegenerate, onDelete, translate }) {
115
161
  const normalized = normalizeActions(actions);
116
162
  const items = [];
117
163
  if (normalized.copy) {
@@ -128,6 +174,7 @@ function BubbleActions({ actions, textContent, messageId, onFeedback, onRegenera
128
174
  key: 'feedback',
129
175
  label: translate('agent.message.feedback'),
130
176
  actionRender: ()=>/*#__PURE__*/ React.createElement(Actions.Feedback, {
177
+ value: feedbackValue ?? 'default',
131
178
  onChange: (rating)=>onFeedback?.({
132
179
  messageId,
133
180
  rating
@@ -176,7 +223,7 @@ function SourcesDisplay({ sourceParts, config, translate }) {
176
223
  expandIconPosition: config?.sourcesDisplay?.expandIconPosition ?? 'end'
177
224
  });
178
225
  }
179
- function MessageBubble({ content, isStreaming, parts, config, addToolApprovalResponse, actions, messageId, onFeedback, onRegenerate, onDelete, translate }) {
226
+ function MessageBubble({ content, isStreaming, parts, config, addToolApprovalResponse, actions, messageId, onFeedback, feedbackValue, onLinkClick, Link, onRegenerate, onDelete, translate }) {
180
227
  const showThoughtChain = config?.showThoughtChain !== false;
181
228
  const showReasoning = config?.showReasoning !== false;
182
229
  const reasoningDisplay = config?.reasoningDisplay ?? 'interleaved';
@@ -186,18 +233,22 @@ function MessageBubble({ content, isStreaming, parts, config, addToolApprovalRes
186
233
  const renderLatex = config?.renderLatex ?? false;
187
234
  const markdownConfig = renderLatex ? getLatexConfig() : undefined;
188
235
  const markdownComponents = useMemo(()=>{
189
- if (!renderMermaid && !codeHighlighter) return {
190
- code: PlainCodeBlock
191
- };
236
+ const code = !renderMermaid && !codeHighlighter ? PlainCodeBlock : RichCodeBlock({
237
+ renderMermaid,
238
+ codeHighlighter
239
+ });
192
240
  return {
193
- code: RichCodeBlock({
194
- renderMermaid,
195
- codeHighlighter
241
+ code,
242
+ a: MarkdownLink({
243
+ Link,
244
+ onLinkClick
196
245
  })
197
246
  };
198
247
  }, [
199
248
  renderMermaid,
200
- codeHighlighter
249
+ codeHighlighter,
250
+ Link,
251
+ onLinkClick
201
252
  ]);
202
253
  const normalizedActions = normalizeActions(actions);
203
254
  const showActions = Object.values(normalizedActions).some(Boolean) && !isStreaming;
@@ -219,6 +270,7 @@ function MessageBubble({ content, isStreaming, parts, config, addToolApprovalRes
219
270
  textContent: content,
220
271
  messageId: messageId,
221
272
  onFeedback: onFeedback,
273
+ feedbackValue: feedbackValue,
222
274
  onRegenerate: onRegenerate,
223
275
  onDelete: onDelete,
224
276
  translate: translate
@@ -491,6 +543,7 @@ function MessageBubble({ content, isStreaming, parts, config, addToolApprovalRes
491
543
  textContent: allTextContent,
492
544
  messageId: messageId,
493
545
  onFeedback: onFeedback,
546
+ feedbackValue: feedbackValue,
494
547
  onRegenerate: onRegenerate,
495
548
  onDelete: onDelete,
496
549
  translate: translate
@@ -21,6 +21,7 @@ import { hasVisibleContent } from './messageParts.js';
21
21
  import MessageBubble from './MessageBubble.js';
22
22
  import ThinkingIndicator from './ThinkingIndicator.js';
23
23
  import useThinkingMessage from './useThinkingMessage.js';
24
+ import WelcomeScreen from './WelcomeScreen.js';
24
25
  function roleAvatar(roleConfig, fallbackIcon) {
25
26
  if (roleConfig?.avatar) {
26
27
  return /*#__PURE__*/ React.createElement(Avatar, {
@@ -55,7 +56,7 @@ function ThinkingBubbleContent({ bubbleId, config }) {
55
56
  label: label
56
57
  });
57
58
  }
58
- const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messages, isStreaming, config, addToolApprovalResponse, onFeedback, onRegenerate, onDelete, onEditMessage, translate }, ref) {
59
+ const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messages, isStreaming, config, welcome, onWelcomePromptFill, addToolApprovalResponse, onFeedback, onLinkClick, Link, feedbackValues, onRegenerate, onDelete, onEditMessage, translate }, ref) {
59
60
  // Build a lookup map for message parts.
60
61
  // Bubble.List's contentRender callback only receives (content, info) where info.key is the
61
62
  // item key — it does not receive the full message object with its parts array. This map
@@ -119,10 +120,36 @@ const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messag
119
120
  streaming: true
120
121
  });
121
122
  }
123
+ // The welcome renders as a leading item so it scrolls up with the transcript
124
+ // and stays reachable by scrolling back, rather than being swapped out on the
125
+ // first send. With no messages yet it is simply the only item in the list.
126
+ if (welcome?.tracks) {
127
+ items.unshift({
128
+ key: '__welcome__',
129
+ role: 'welcome'
130
+ });
131
+ }
122
132
  return /*#__PURE__*/ React.createElement(Bubble.List, {
123
133
  ref: ref,
124
134
  items: items,
125
135
  role: {
136
+ welcome: {
137
+ placement: 'start',
138
+ variant: 'borderless',
139
+ style: {
140
+ maxWidth: '100%'
141
+ },
142
+ styles: {
143
+ content: {
144
+ width: '100%'
145
+ }
146
+ },
147
+ contentRender: ()=>/*#__PURE__*/ React.createElement(WelcomeScreen, {
148
+ config: welcome,
149
+ onFill: onWelcomePromptFill,
150
+ inFlow: true
151
+ })
152
+ },
126
153
  user: {
127
154
  placement: 'end',
128
155
  variant: config?.roles?.user?.variant ?? 'filled',
@@ -189,6 +216,9 @@ const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messag
189
216
  actions: config?.actions,
190
217
  messageId: info.key,
191
218
  onFeedback: onFeedback,
219
+ onLinkClick: onLinkClick,
220
+ Link: Link,
221
+ feedbackValue: feedbackValues?.[info.key],
192
222
  onRegenerate: onRegenerate,
193
223
  onDelete: onDelete,
194
224
  translate: translate
@@ -14,8 +14,95 @@
14
14
  limitations under the License.
15
15
  */ import React from 'react';
16
16
  import { Welcome, Prompts } from '@ant-design/x';
17
- function WelcomeScreen({ config, onPromptClick }) {
17
+ import { Button } from 'antd';
18
+ // One track: a bordered card holding a heading and a column of starter chips.
19
+ // A chip fills the composer (onFill) rather than sending — a near-miss starter
20
+ // becomes an editable first draft, not a message the user never meant to send.
21
+ function TrackCard({ track, onFill }) {
22
+ const prompts = track.prompts ?? [];
23
+ return /*#__PURE__*/ React.createElement("div", {
24
+ style: {
25
+ flex: '1 1 240px',
26
+ border: '1px solid var(--ant-color-border)',
27
+ borderRadius: 'var(--ant-border-radius-lg)',
28
+ padding: 16,
29
+ backgroundColor: 'var(--ant-color-bg-container)',
30
+ display: 'flex',
31
+ flexDirection: 'column',
32
+ gap: 8
33
+ }
34
+ }, track.label && /*#__PURE__*/ React.createElement("h5", {
35
+ style: {
36
+ margin: '0 0 4px',
37
+ fontSize: 'var(--ant-font-size-heading-5)',
38
+ fontWeight: 'var(--ant-font-weight-strong, 600)',
39
+ lineHeight: 'var(--ant-line-height-heading-5)'
40
+ }
41
+ }, track.label), prompts.map((prompt, index)=>{
42
+ const text = typeof prompt === 'string' ? prompt : prompt.label;
43
+ return /*#__PURE__*/ React.createElement(Button, {
44
+ key: index,
45
+ block: true,
46
+ // antd buttons are nowrap and size to their longest line; height auto
47
+ // plus whiteSpace normal lets a sentence-length starter wrap left.
48
+ style: {
49
+ height: 'auto',
50
+ whiteSpace: 'normal',
51
+ textAlign: 'left',
52
+ paddingTop: 6,
53
+ paddingBottom: 6
54
+ },
55
+ onClick: ()=>onFill?.(text)
56
+ }, text);
57
+ }));
58
+ }
59
+ // The chat empty state. Two modes:
60
+ // - `config.tracks` present → a two-track teaching panel (ask a question /
61
+ // build a report), each track a card of fill-the-composer starters. When
62
+ // `inFlow` it renders top-aligned as a leading item inside the message
63
+ // scroll area, so it scrolls up with the conversation and stays reachable
64
+ // by scrolling back rather than being swapped out on the first send.
65
+ // - otherwise → the flat Welcome + Prompts row, centred, sending on click.
66
+ function WelcomeScreen({ config, onPromptClick, onFill, inFlow }) {
18
67
  if (!config) return null;
68
+ if (config.tracks) {
69
+ return /*#__PURE__*/ React.createElement("div", {
70
+ style: inFlow ? {
71
+ display: 'flex',
72
+ flexDirection: 'column',
73
+ gap: 16,
74
+ width: '100%'
75
+ } : {
76
+ flex: 1,
77
+ display: 'flex',
78
+ flexDirection: 'column',
79
+ alignItems: 'center',
80
+ justifyContent: 'center',
81
+ gap: 16
82
+ }
83
+ }, config.title && /*#__PURE__*/ React.createElement("h3", {
84
+ style: {
85
+ margin: 0,
86
+ fontSize: 'var(--ant-font-size-heading-3)',
87
+ fontWeight: 'var(--ant-font-weight-strong, 600)',
88
+ lineHeight: 'var(--ant-line-height-heading-3)'
89
+ }
90
+ }, config.title), config.description && /*#__PURE__*/ React.createElement("div", {
91
+ style: {
92
+ color: 'var(--ant-color-text-secondary)'
93
+ }
94
+ }, config.description), /*#__PURE__*/ React.createElement("div", {
95
+ style: {
96
+ display: 'flex',
97
+ gap: 12,
98
+ flexWrap: 'wrap'
99
+ }
100
+ }, config.tracks.map((track, index)=>/*#__PURE__*/ React.createElement(TrackCard, {
101
+ key: index,
102
+ track: track,
103
+ onFill: onFill
104
+ }))));
105
+ }
19
106
  const promptItems = (config.prompts ?? []).map((prompt, index)=>({
20
107
  key: prompt.key ?? `prompt-${index}`,
21
108
  label: prompt.label,
@@ -23,7 +23,7 @@
23
23
  onUserMessage: 'Trigger when the user sends a message.',
24
24
  onConversationStart: 'Trigger once when a conversation starts, on its first user message. Event contains the conversationId (auto-minted when no conversationId property is set).',
25
25
  onError: 'Trigger on stream error.',
26
- onFeedback: 'Trigger when the user clicks thumbs up or down on a message.',
26
+ onFeedback: 'Trigger when the user clicks thumbs up or down on a message. Event contains messageId, messageContent and rating, where rating is `like`, `dislike`, or `default` when the user clicks the selected thumb again to clear the rating.',
27
27
  onRegenerate: 'Trigger when the user clicks regenerate on a message.',
28
28
  onDeleteMessage: 'Trigger when the user deletes a message.',
29
29
  onEditMessage: 'Trigger when the user edits and resubmits a message.',
@@ -32,7 +32,8 @@
32
32
  onSuggestionClick: 'Trigger when the user clicks a follow-up suggestion.',
33
33
  onTitleGenerated: 'Trigger when the agent generates a conversation title.',
34
34
  onSwitchChange: 'Trigger when the user toggles a sender switch.',
35
- onDataPart: 'Trigger when the agent sends a custom data part. Event contains type, data, and id.'
35
+ onDataPart: 'Trigger when the agent sends a custom data part. Event contains type, data, and id.',
36
+ onLinkClick: 'Trigger when the user clicks a link in a message. Event contains href and text. Wiring this event suppresses the default navigation for plain left clicks, so the app can open the target in place; modified clicks (new tab, middle click) always navigate.'
36
37
  },
37
38
  methods: {
38
39
  regenerate: 'Regenerate the last assistant message. Accepts optional args.messageId to regenerate a specific message.',
@@ -162,6 +162,10 @@
162
162
  "messages": {
163
163
  "type": "array"
164
164
  },
165
+ "feedbackValues": {
166
+ "type": "object",
167
+ "description": "Ratings already recorded, keyed by message id, each `like` or `dislike`. Restores the thumbs on a conversation the block did not rate this visit. A rating clicked this visit takes precedence."
168
+ },
165
169
  "conversationId": {
166
170
  "type": "string",
167
171
  "description": "Active conversation ID. When this value changes, messages are automatically cleared."
@@ -186,13 +190,28 @@
186
190
  "allowSpeech": { "type": "boolean", "default": false },
187
191
  "attachments": {
188
192
  "type": "object",
193
+ "description": "File attachments on the composer. When enabled, files can be picked with the paperclip, pasted from the clipboard (e.g. a screenshot) or dragged onto the composer; accept and maxSize apply to all three.",
189
194
  "properties": {
190
195
  "enabled": { "type": "boolean" },
191
- "accept": { "type": "string" },
192
- "maxSize": { "type": "number" },
196
+ "accept": {
197
+ "type": "string",
198
+ "description": "Comma-separated accept list, as for an <input type=\"file\"> — e.g. \"image/*,.pdf\". Applied to picked, pasted and dropped files alike."
199
+ },
200
+ "maxSize": {
201
+ "type": "number",
202
+ "description": "Maximum file size in bytes. Larger files are silently skipped."
203
+ },
204
+ "uploadPolicyRequestId": {
205
+ "type": "string",
206
+ "description": "Id of an upload-policy request (e.g. AwsS3PresignedPostPolicy, GcsSignedPostPolicy, AzureBlobUploadSas). When set, files are uploaded to storage and their URLs are sent to the LLM instead of base64 data."
207
+ },
208
+ "downloadPolicyRequestId": {
209
+ "type": "string",
210
+ "description": "Id of a download request used to resolve the URL of an uploaded attachment. The URL is fetched by the model provider, so it should remain reachable — use public: true on the request for public buckets, or a long expiry. When unset, a legacy unsigned object URL is constructed from the upload response (S3-shaped, deprecated)."
211
+ },
193
212
  "s3PostPolicyRequestId": {
194
213
  "type": "string",
195
- "description": "Request ID of type AwsS3PresignedPostPolicy. When set, files are uploaded to S3 and their URLs are sent to the LLM instead of base64 data."
214
+ "description": "Deprecated — use uploadPolicyRequestId instead."
196
215
  }
197
216
  }
198
217
  },
@@ -0,0 +1,121 @@
1
+ # Copyright 2020-2026 Lowdefy, Inc
2
+
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ id: agent-chat
16
+ type: PageHeaderMenu
17
+ properties:
18
+ title: AgentChat
19
+ blocks:
20
+ # Both chats are driven from the messages property, so nothing here calls the agent.
21
+ - id: chat_wired
22
+ type: AgentChat
23
+ properties:
24
+ agentId: test_agent
25
+ height: 300
26
+ conversationId: wired
27
+ messageDisplay:
28
+ actions:
29
+ - feedback
30
+ messages:
31
+ - id: w1
32
+ role: assistant
33
+ parts:
34
+ - type: text
35
+ text: |
36
+ [in-app link](/agent-chat-target) and [external link](https://example.com)
37
+ and [**bold** link](/agent-chat-target?tab=two)
38
+ events:
39
+ onFeedback:
40
+ - id: record_rating
41
+ type: SetState
42
+ params:
43
+ last_rating:
44
+ _event: rating
45
+ onLinkClick:
46
+ - id: record_link_click
47
+ type: SetState
48
+ params:
49
+ last_href:
50
+ _event: href
51
+ last_text:
52
+ _event: text
53
+ click_count:
54
+ _sum:
55
+ - _if_none:
56
+ - _state: click_count
57
+ - 0
58
+ - 1
59
+
60
+ # Seeded from feedbackValues rather than from a click: what a reload or a conversation
61
+ # switch looks like to an app that stored the rating itself.
62
+ - id: chat_seeded
63
+ type: AgentChat
64
+ properties:
65
+ agentId: test_agent
66
+ height: 300
67
+ conversationId: seeded
68
+ messageDisplay:
69
+ actions:
70
+ - feedback
71
+ feedbackValues:
72
+ s1: like
73
+ messages:
74
+ - id: s1
75
+ role: assistant
76
+ parts:
77
+ - type: text
78
+ text: A rated answer.
79
+ events:
80
+ onFeedback:
81
+ - id: record_seeded_rating
82
+ type: SetState
83
+ params:
84
+ seeded_rating:
85
+ _event: rating
86
+
87
+ - id: wired_readout
88
+ type: Html
89
+ properties:
90
+ html:
91
+ _nunjucks:
92
+ template: |
93
+ <span id="readout_href">{{ href }}</span>
94
+ <span id="readout_text">{{ text }}</span>
95
+ <span id="readout_count">{{ count or 0 }}</span>
96
+ <span id="readout_rating">{{ rating }}</span>
97
+ <span id="readout_seeded_rating">{{ seeded_rating }}</span>
98
+ on:
99
+ href:
100
+ _state: last_href
101
+ text:
102
+ _state: last_text
103
+ count:
104
+ _state: click_count
105
+ rating:
106
+ _state: last_rating
107
+ seeded_rating:
108
+ _state: seeded_rating
109
+
110
+ - id: chat_unwired
111
+ type: AgentChat
112
+ properties:
113
+ agentId: test_agent
114
+ height: 300
115
+ conversationId: unwired
116
+ messages:
117
+ - id: u1
118
+ role: assistant
119
+ parts:
120
+ - type: text
121
+ text: '[in-app link](/agent-chat-target) and [external link](https://example.com)'
@@ -0,0 +1,25 @@
1
+ # Copyright 2020-2026 Lowdefy, Inc
2
+
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ id: agent-chat-target
16
+ type: PageHeaderMenu
17
+ properties:
18
+ title: Link target
19
+ blocks:
20
+ - id: target_marker
21
+ type: Html
22
+ properties:
23
+ # No id on the span: Lowdefy renders the block id into the DOM, so repeating it here
24
+ # gives two matching elements and a strict-mode violation.
25
+ html: <span>arrived</span>
@@ -82,9 +82,6 @@ function AgentConversations({ blockId, methods, properties }) {
82
82
  }
83
83
  AgentConversations.meta = {
84
84
  category: 'display',
85
- icons: [],
86
- styles: [
87
- 'blocks/AgentConversations/style.less'
88
- ]
85
+ icons: []
89
86
  };
90
87
  export default AgentConversations;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/blocks-antd-x",
3
- "version": "5.5.1",
3
+ "version": "6.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Lowdefy Ant Design X Blocks",
6
6
  "homepage": "https://lowdefy.com",
@@ -41,13 +41,17 @@
41
41
  "@ant-design/icons": "6.1.0",
42
42
  "@ant-design/x": "2.7.0",
43
43
  "@ant-design/x-markdown": "2.7.0",
44
- "@lowdefy/block-utils": "5.5.1",
45
- "@lowdefy/helpers": "5.5.1",
44
+ "@lowdefy/block-utils": "6.0.0",
45
+ "@lowdefy/blocks-files": "6.0.0",
46
+ "@lowdefy/helpers": "6.0.0",
46
47
  "ai": "6.0.176"
47
48
  },
48
49
  "devDependencies": {
49
- "@swc/cli": "0.8.0",
50
- "@swc/core": "1.15.18",
50
+ "@lowdefy/block-dev-e2e": "6.0.0",
51
+ "@lowdefy/e2e-utils": "6.0.0",
52
+ "@playwright/test": "1.59.1",
53
+ "@swc/cli": "0.8.1",
54
+ "@swc/core": "1.15.32",
51
55
  "copyfiles": "2.4.1"
52
56
  },
53
57
  "peerDependencies": {
@@ -60,6 +64,8 @@
60
64
  "scripts": {
61
65
  "build": "swc src --out-dir dist --config-file ../../../../.swcrc --cli-config-file ../../../../.swc-cli.json && pnpm copyfiles",
62
66
  "clean": "rm -rf dist",
63
- "copyfiles": "copyfiles -u 1 \"./src/**/*\" dist -e \"./src/**/*.js\" -e \"./src/**/*.yaml\" -e \"./src/**/*.snap\""
67
+ "copyfiles": "copyfiles -u 1 \"./src/**/*\" dist -e \"./src/**/*.js\" -e \"./src/**/*.snap\"",
68
+ "e2e": "playwright test --config e2e/playwright.config.js",
69
+ "e2e:ui": "playwright test --config e2e/playwright.config.js --ui"
64
70
  }
65
71
  }
@@ -1 +0,0 @@
1
- // AgentConversations styles — currently uses inline styles and Ant Design X defaults.