@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.
- package/dist/blocks/AgentChat/AgentChat.js +38 -889
- package/dist/blocks/AgentChat/AgentChat.lazy.js +933 -0
- package/dist/blocks/AgentChat/DrawerLauncher.js +32 -0
- package/dist/blocks/AgentChat/DrawerWrapper.js +9 -8
- package/dist/blocks/AgentChat/LowdefyChatTransport.js +8 -2
- package/dist/blocks/AgentChat/MessageBubble.js +105 -40
- package/dist/blocks/AgentChat/MessageList.js +16 -4
- package/dist/blocks/AgentChat/formatToolResult.js +2 -1
- package/dist/blocks/AgentChat/meta.js +17 -1
- package/dist/blocks/AgentChat/schema.json +7 -2
- package/dist/blocks/AgentChat/tests/AgentChat.e2e.yaml +19 -0
- package/dist/blocks/AgentConversations/AgentConversations.js +18 -3
- package/dist/blocks/AgentConversations/meta.js +5 -1
- package/dist/blocks/AgentConversations/schema.json +8 -0
- package/dist/blocks/AgentConversations/tests/AgentConversations.e2e.yaml +69 -0
- package/dist/blocks/IntentInput/IntentInput.js +230 -0
- package/dist/blocks/IntentInput/IntentInput.module.css +159 -0
- package/dist/blocks/IntentInput/answerScores.js +34 -0
- package/dist/blocks/IntentInput/gallery.yaml +81 -0
- package/dist/blocks/IntentInput/meta.js +164 -0
- package/dist/blocks/IntentInput/nextIntentState.js +62 -0
- package/dist/blocks.js +1 -0
- package/dist/metas.js +1 -0
- package/dist/types.js +3 -18
- package/package.json +14 -12
|
@@ -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;
|