@stack-spot/ai-chat-widget 1.24.3 → 1.25.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.
Files changed (108) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/app-metadata.json +3 -3
  3. package/dist/chat-interceptors/send-message.js +3 -3
  4. package/dist/chat-interceptors/send-message.js.map +1 -1
  5. package/dist/components/FileDescription.d.ts +10 -0
  6. package/dist/components/FileDescription.d.ts.map +1 -0
  7. package/dist/components/FileDescription.js +85 -0
  8. package/dist/components/FileDescription.js.map +1 -0
  9. package/dist/state/ChatEntry.d.ts +9 -0
  10. package/dist/state/ChatEntry.d.ts.map +1 -1
  11. package/dist/state/ChatEntry.js.map +1 -1
  12. package/dist/state/ChatState.d.ts +5 -0
  13. package/dist/state/ChatState.d.ts.map +1 -1
  14. package/dist/state/ChatState.js +6 -0
  15. package/dist/state/ChatState.js.map +1 -1
  16. package/dist/state/constants.d.ts +5 -0
  17. package/dist/state/constants.d.ts.map +1 -0
  18. package/dist/state/constants.js +9 -0
  19. package/dist/state/constants.js.map +1 -0
  20. package/dist/state/types.d.ts +4 -0
  21. package/dist/state/types.d.ts.map +1 -1
  22. package/dist/utils/chat.d.ts +2 -1
  23. package/dist/utils/chat.d.ts.map +1 -1
  24. package/dist/utils/chat.js +2 -1
  25. package/dist/utils/chat.js.map +1 -1
  26. package/dist/utils/upload/FileUpload.d.ts +21 -0
  27. package/dist/utils/upload/FileUpload.d.ts.map +1 -0
  28. package/dist/utils/upload/FileUpload.js +55 -0
  29. package/dist/utils/upload/FileUpload.js.map +1 -0
  30. package/dist/utils/upload/UploadManager.d.ts +40 -0
  31. package/dist/utils/upload/UploadManager.d.ts.map +1 -0
  32. package/dist/utils/upload/UploadManager.js +131 -0
  33. package/dist/utils/upload/UploadManager.js.map +1 -0
  34. package/dist/utils/upload/context.d.ts +15 -0
  35. package/dist/utils/upload/context.d.ts.map +1 -0
  36. package/dist/utils/upload/context.js +37 -0
  37. package/dist/utils/upload/context.js.map +1 -0
  38. package/dist/utils/upload/errors.d.ts +17 -0
  39. package/dist/utils/upload/errors.d.ts.map +1 -0
  40. package/dist/utils/upload/errors.js +27 -0
  41. package/dist/utils/upload/errors.js.map +1 -0
  42. package/dist/utils/upload/types.d.ts +7 -0
  43. package/dist/utils/upload/types.d.ts.map +1 -0
  44. package/dist/utils/upload/types.js +2 -0
  45. package/dist/utils/upload/types.js.map +1 -0
  46. package/dist/utils/upload/utils.d.ts +4 -0
  47. package/dist/utils/upload/utils.d.ts.map +1 -0
  48. package/dist/utils/upload/utils.js +10 -0
  49. package/dist/utils/upload/utils.js.map +1 -0
  50. package/dist/views/Chat/AgentInfo.d.ts +2 -1
  51. package/dist/views/Chat/AgentInfo.d.ts.map +1 -1
  52. package/dist/views/Chat/AgentInfo.js +2 -2
  53. package/dist/views/Chat/AgentInfo.js.map +1 -1
  54. package/dist/views/Chat/ChatMessage.d.ts +1 -1
  55. package/dist/views/Chat/ChatMessage.d.ts.map +1 -1
  56. package/dist/views/Chat/ChatMessage.js +26 -7
  57. package/dist/views/Chat/ChatMessage.js.map +1 -1
  58. package/dist/views/Chat/styled.d.ts.map +1 -1
  59. package/dist/views/Chat/styled.js +23 -1
  60. package/dist/views/Chat/styled.js.map +1 -1
  61. package/dist/views/MessageInput/{InfoBar.d.ts → ContextBar.d.ts} +2 -2
  62. package/dist/views/MessageInput/ContextBar.d.ts.map +1 -0
  63. package/dist/views/MessageInput/{InfoBar.js → ContextBar.js} +5 -5
  64. package/dist/views/MessageInput/ContextBar.js.map +1 -0
  65. package/dist/views/MessageInput/SelectContent.d.ts.map +1 -1
  66. package/dist/views/MessageInput/SelectContent.js +14 -17
  67. package/dist/views/MessageInput/SelectContent.js.map +1 -1
  68. package/dist/views/MessageInput/UploadBar.d.ts +2 -0
  69. package/dist/views/MessageInput/UploadBar.d.ts.map +1 -0
  70. package/dist/views/MessageInput/UploadBar.js +47 -0
  71. package/dist/views/MessageInput/UploadBar.js.map +1 -0
  72. package/dist/views/MessageInput/dictionary.d.ts +1 -1
  73. package/dist/views/MessageInput/dictionary.d.ts.map +1 -1
  74. package/dist/views/MessageInput/dictionary.js +18 -4
  75. package/dist/views/MessageInput/dictionary.js.map +1 -1
  76. package/dist/views/MessageInput/index.d.ts.map +1 -1
  77. package/dist/views/MessageInput/index.js +46 -5
  78. package/dist/views/MessageInput/index.js.map +1 -1
  79. package/dist/views/MessageInput/styled.d.ts.map +1 -1
  80. package/dist/views/MessageInput/styled.js +56 -27
  81. package/dist/views/MessageInput/styled.js.map +1 -1
  82. package/dist/views/Steps/dictionary.d.ts +1 -1
  83. package/package.json +2 -2
  84. package/src/app-metadata.json +3 -3
  85. package/src/chat-interceptors/send-message.ts +3 -3
  86. package/src/components/FileDescription.tsx +114 -0
  87. package/src/state/ChatEntry.ts +10 -0
  88. package/src/state/ChatState.ts +6 -0
  89. package/src/state/constants.ts +12 -0
  90. package/src/state/types.ts +5 -0
  91. package/src/utils/chat.ts +3 -1
  92. package/src/utils/upload/FileUpload.ts +64 -0
  93. package/src/utils/upload/UploadManager.ts +147 -0
  94. package/src/utils/upload/context.tsx +44 -0
  95. package/src/utils/upload/errors.ts +34 -0
  96. package/src/utils/upload/types.ts +7 -0
  97. package/src/utils/upload/utils.ts +12 -0
  98. package/src/views/Chat/AgentInfo.tsx +3 -2
  99. package/src/views/Chat/ChatMessage.tsx +139 -113
  100. package/src/views/Chat/styled.ts +23 -1
  101. package/src/views/MessageInput/{InfoBar.tsx → ContextBar.tsx} +9 -9
  102. package/src/views/MessageInput/SelectContent.tsx +17 -21
  103. package/src/views/MessageInput/UploadBar.tsx +69 -0
  104. package/src/views/MessageInput/dictionary.ts +18 -4
  105. package/src/views/MessageInput/index.tsx +77 -32
  106. package/src/views/MessageInput/styled.ts +56 -27
  107. package/dist/views/MessageInput/InfoBar.d.ts.map +0 -1
  108. package/dist/views/MessageInput/InfoBar.js.map +0 -1
@@ -4,9 +4,11 @@ import { Badge, IconButton, Tooltip } from '@citric/ui'
4
4
  import { agentClient } from '@stack-spot/portal-network'
5
5
  import { listToClass } from '@stack-spot/portal-theme'
6
6
  import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
7
+ import { groupBy } from 'lodash'
7
8
  import { createElement, Dispatch, useCallback, useMemo, useRef, useState } from 'react'
8
9
  import { PhoneInput } from 'react-international-phone'
9
10
  import 'react-international-phone/style.css'
11
+ import { FileDescription } from '../../components/FileDescription'
10
12
  import { Markdown } from '../../components/Markdown'
11
13
  import { StackedBadge } from '../../components/StackedBadge'
12
14
  import { useChatEntry, useCurrentChat, useWidget } from '../../context/hooks'
@@ -166,6 +168,14 @@ const RenderInputsEntry = ({ isLast, entry, value, setValue, labels, setLabels }
166
168
  </Flex>
167
169
  }
168
170
 
171
+ const UserInfo = ({ entry }: { entry: TextChatEntry }) => {
172
+ switch (entry.agentType) {
173
+ case 'user': return
174
+ case 'bot': return <AgentInfo agent={entry.agent} />
175
+ case 'system': return <AgentInfo agent={{ id: 'system', label: 'System' }} icon={<Cog />} />
176
+ }
177
+ }
178
+
169
179
  /**
170
180
  * Renders a message (ChatEntry) in the chat.
171
181
  */
@@ -176,7 +186,6 @@ export const ChatMessage = ({ message, isLast, beforeMessage, afterMessage }: Pr
176
186
  const [labels, setLabels] = useState<string[]>(message.getValue()?.initialValue ?? [])
177
187
  const entry = useChatEntry(message)
178
188
  const dateFormatter = useDateFormatter()
179
- const userInfo = entry.agentType === 'user' ? <></> : <AgentInfo agent={entry.agent} />
180
189
  const date = new Date(entry.updated ?? '')
181
190
  const shouldShowFooter = entry.updated && !isNaN(date.getTime())
182
191
  const ref = useRef<HTMLLIElement>(null)
@@ -259,9 +268,26 @@ export const ChatMessage = ({ message, isLast, beforeMessage, afterMessage }: Pr
259
268
  setTimeout(() => setCopied(false), 2000)
260
269
  }
261
270
 
271
+ const renderUploads = () => {
272
+ const groups = groupBy(entry.upload, f => f.image ? 'images' : 'documents')
273
+ const lists: React.ReactElement[] = []
274
+ if (groups.images?.length) {
275
+ lists.push(<ul className="image-uploads">{groups.images.map(f => <li key={f.id}><img src={f.image}></img></li>)}</ul>)
276
+ }
277
+ if (groups.documents?.length) {
278
+ lists.push(
279
+ <ul className="document-uploads">
280
+ {groups.documents.map(f => <li key={f.id}><FileDescription fileName={f.name} /></li>)}
281
+ </ul>,
282
+ )
283
+ }
284
+ return lists
285
+ }
286
+
262
287
  const renderContent = () => {
263
288
  if (entry.type === 'md') {
264
289
  return <>
290
+ {renderUploads()}
265
291
  <Markdown onCopyCode={(code) => onCopyCode(code, entry.messageId ?? '', chat)}>{entry.content}</Markdown>
266
292
  {renderActions()}
267
293
  </>
@@ -269,6 +295,7 @@ export const ChatMessage = ({ message, isLast, beforeMessage, afterMessage }: Pr
269
295
  if (entry.type === 'text') {
270
296
  return <>
271
297
  <p className="plain-text">{entry.content}</p>
298
+ {renderUploads()}
272
299
  {renderActions()}
273
300
  </>
274
301
  }
@@ -287,133 +314,132 @@ export const ChatMessage = ({ message, isLast, beforeMessage, afterMessage }: Pr
287
314
  }
288
315
 
289
316
 
290
- return (entry.content || entry.error || !!entry.steps?.length) && (
291
- <li key={entry.messageId} className={entry.agentType} ref={ref}
292
- >
293
- <div className="chat-message" ref={chatRef} onKeyDown={handleKeyDown} tabIndex={0}
294
- onMouseEnter={entry.agentType === 'user' ? () => setShowUserButtonCopy(true) : undefined}
295
- onMouseLeave={entry.agentType === 'user' ? () => setShowUserButtonCopy(false) : undefined}>
296
- <div className={`user-info ${entry.agentType}`}>{userInfo}</div>
297
- {beforeMessage && createElement(beforeMessage, { message })}
298
- {(entry.content || entry.steps) && <div className={listToClass(['message-content', entry.card && 'card', entry.type])}>
299
- {!!entry.badges?.length && <div className="badges">
300
- {entry.badges.map((b, index) => <Badge key={index} palette={b.color ?? 'cyan'} appearance="square">{b.label}</Badge>)}
301
- </div>}
302
- {renderContent()}
303
-
304
- {!!entry.steps?.length && <StepsList steps={entry.steps} chatId={chat.id} messageId={message.id} />}
305
- </div>}
306
-
307
- {entry.error && (
308
- <div className="error">
309
- <IconBox size="xs"><TimesCircle /></IconBox>
310
- <Text appearance="microtext1">{entry.error}</Text>
311
- </div>
312
- )}
313
- </div>
314
- {afterMessage && createElement(afterMessage, { message })}
315
- {!!entry.tools?.length && <StackedBadge
316
- aria-label={t.openToolsPanel}
317
- title={t.openToolsPanel}
318
- tabIndex={0}
319
- role="button"
320
- className="tools-badge"
321
- label={t.tools}
322
- images={entry.tools.slice(0, 3).map((id) => {
323
- const tool = toolById(id, agent?.toolkits)
324
- return { key: id, name: tool?.name || id, icon: <Cog />, url: tool?.image }
325
- })}
326
- onClick={openToolsPanel}
327
- />}
328
- {!!entry.knowledgeSources?.length && <div className="ks-box">
329
- <Text appearance="microtext1" colorScheme="light.700">Knowledge Sources:</Text>
330
- <ul>{entry.knowledgeSources.map((ks, index) => (
331
- <li key={index}>
332
- <Button size="sm" colorScheme="light" onClick={() => detailKS(ks)}>{ks.name}</Button>
333
- </li>
334
- ))}</ul>
335
- </div>}
336
-
337
- {shouldShowFooter && <div className="message-footer"
317
+ return (entry.content || entry.error || !!entry.steps?.length || entry.upload?.length) && (
318
+ <li key={entry.messageId} className={entry.agentType} ref={ref}>
319
+ <div className="chat-message-container"
338
320
  onMouseEnter={entry.agentType === 'user' ? () => setShowUserButtonCopy(true) : undefined}
339
321
  onMouseLeave={entry.agentType === 'user' ? () => setShowUserButtonCopy(false) : undefined}>
340
-
341
- {entry.agentType === 'bot' && !entry.error && <div className="message-actions">
342
-
343
- {entry.type === 'md' && (
344
- copied ? (
345
- <Tooltip text={t.copied} position={'right'}>
346
- <IconButton
347
- appearance="square"
348
- colorBg="light"
349
- title={t.copied}
350
- aria-label={t.copied}
351
- onClick={handleCopy}
352
- >
353
- <Check />
354
- </IconButton>
355
- </Tooltip>
356
- ) : (
357
- <IconButton
358
- appearance="square"
359
- color="light"
360
- title={t.copy}
361
- aria-label={t.copy}
362
- onClick={handleCopy}
363
- >
364
- <Copy />
365
- </IconButton>
366
- )
322
+ <div className="chat-message" ref={chatRef} onKeyDown={handleKeyDown} tabIndex={0}>
323
+ <div className={`user-info ${entry.agentType}`}><UserInfo entry={entry} /></div>
324
+ {beforeMessage && createElement(beforeMessage, { message })}
325
+ {(entry.content || entry.steps || entry.upload?.length) && (
326
+ <div className={listToClass(['message-content', entry.card && 'card', entry.type])}>
327
+ {!!entry.badges?.length && <div className="badges">
328
+ {entry.badges.map((b, index) => <Badge key={index} palette={b.color ?? 'cyan'} appearance="square">{b.label}</Badge>)}
329
+ </div>}
330
+ {renderContent()}
331
+
332
+ {!!entry.steps?.length && <StepsList steps={entry.steps} chatId={chat.id} messageId={message.id} />}
333
+ </div>
367
334
  )}
368
- {entry.messageId && (
369
- <>
370
- <IconButton appearance="square" color="light" title={t.like} aria-label={t.like} onClick={like}>
371
- {liked === true ? <LikeFill /> : <Like />}
372
- </IconButton>
373
- <IconButton appearance="square" color="light" title={t.dislike} aria-label={t.dislike} onClick={dislike}>
374
- {liked === false ? <DislikeFill /> : <Dislike />}
375
- </IconButton>
376
- </>
335
+
336
+ {entry.error && (
337
+ <div className="error">
338
+ <IconBox size="xs"><TimesCircle /></IconBox>
339
+ <Text appearance="microtext1">{entry.error}</Text>
340
+ </div>
377
341
  )}
342
+ </div>
343
+ {afterMessage && createElement(afterMessage, { message })}
344
+ {!!entry.tools?.length && <StackedBadge
345
+ aria-label={t.openToolsPanel}
346
+ title={t.openToolsPanel}
347
+ tabIndex={0}
348
+ role="button"
349
+ className="tools-badge"
350
+ label={t.tools}
351
+ images={entry.tools.slice(0, 3).map((id) => {
352
+ const tool = toolById(id, agent?.toolkits)
353
+ return { key: id, name: tool?.name || id, icon: <Cog />, url: tool?.image }
354
+ })}
355
+ onClick={openToolsPanel}
356
+ />}
357
+ {!!entry.knowledgeSources?.length && <div className="ks-box">
358
+ <Text appearance="microtext1" colorScheme="light.700">Knowledge Sources:</Text>
359
+ <ul>{entry.knowledgeSources.map((ks, index) => (
360
+ <li key={index}>
361
+ <Button size="sm" colorScheme="light" onClick={() => detailKS(ks)}>{ks.name}</Button>
362
+ </li>
363
+ ))}</ul>
378
364
  </div>}
379
-
380
- {entry.agentType === 'user' && (
381
- <div className="message-actions">
382
- {copied ? (
383
- <Tooltip text={t.copied} position={'left'}>
365
+
366
+ {shouldShowFooter && <div className="message-footer">
367
+ {entry.agentType === 'bot' && !entry.error && <div className="message-actions">
368
+ {entry.type === 'md' && (
369
+ copied ? (
370
+ <Tooltip text={t.copied} position={'right'}>
371
+ <IconButton
372
+ appearance="square"
373
+ colorBg="light"
374
+ title={t.copied}
375
+ aria-label={t.copied}
376
+ onClick={handleCopy}
377
+ >
378
+ <Check />
379
+ </IconButton>
380
+ </Tooltip>
381
+ ) : (
384
382
  <IconButton
385
383
  appearance="square"
386
- colorBg="light"
387
- title={t.copied}
388
- aria-label={t.copied}
389
- size="sm"
384
+ color="light"
385
+ title={t.copy}
386
+ aria-label={t.copy}
387
+ onClick={handleCopy}
390
388
  >
391
- <Check />
389
+ <Copy />
390
+ </IconButton>
391
+ )
392
+ )}
393
+ {entry.messageId && (
394
+ <>
395
+ <IconButton appearance="square" color="light" title={t.like} aria-label={t.like} onClick={like}>
396
+ {liked === true ? <LikeFill /> : <Like />}
397
+ </IconButton>
398
+ <IconButton appearance="square" color="light" title={t.dislike} aria-label={t.dislike} onClick={dislike}>
399
+ {liked === false ? <DislikeFill /> : <Dislike />}
392
400
  </IconButton>
393
- </Tooltip>
394
- ) : (
395
- showUserButtonCopy && (
396
- <div className="action-bar">
401
+ </>
402
+ )}
403
+ </div>}
404
+
405
+ {entry.agentType === 'user' && (
406
+ <div className="message-actions">
407
+ {copied ? (
408
+ <Tooltip text={t.copied} position={'left'}>
397
409
  <IconButton
398
410
  appearance="square"
399
- color="light"
400
- title={t.copy}
401
- aria-label={t.copy}
402
- onClick={handleCopy}
411
+ colorBg="light"
412
+ title={t.copied}
413
+ aria-label={t.copied}
403
414
  size="sm"
404
415
  >
405
- <Copy className="copy-btn"/>
416
+ <Check />
406
417
  </IconButton>
407
- </div>
408
- )
409
- )}
410
- </div>
411
- )}
418
+ </Tooltip>
419
+ ) : (
420
+ showUserButtonCopy && (
421
+ <div className="action-bar">
422
+ <IconButton
423
+ appearance="square"
424
+ color="light"
425
+ title={t.copy}
426
+ aria-label={t.copy}
427
+ onClick={handleCopy}
428
+ size="sm"
429
+ >
430
+ <Copy className="copy-btn"/>
431
+ </IconButton>
432
+ </div>
433
+ )
434
+ )}
435
+ </div>
436
+ )}
412
437
 
413
- <Text as="label" appearance="microtext1" className="chat-date">
414
- {dateFormatter.formatForChatMessage(date)}
415
- </Text>
416
- </div>}
438
+ <Text as="label" appearance="microtext1" className="chat-date">
439
+ {dateFormatter.formatForChatMessage(date)}
440
+ </Text>
441
+ </div>}
442
+ </div>
417
443
  </li>
418
444
  )
419
445
  }
@@ -39,7 +39,7 @@ export const ChatList: IStyledComponentBase<
39
39
  flex-direction: row;
40
40
  gap: 10px;
41
41
 
42
- &.bot {
42
+ &.bot, &.system {
43
43
  align-items: center;
44
44
  }
45
45
  }
@@ -63,6 +63,7 @@ export const ChatList: IStyledComponentBase<
63
63
  flex-direction: row;
64
64
  align-items: center;
65
65
  gap: 8px;
66
+ margin-top: 10px;
66
67
 
67
68
  .message-actions {
68
69
  display: flex;
@@ -138,12 +139,33 @@ export const ChatList: IStyledComponentBase<
138
139
  background: linear-gradient(180deg, ${theme.color.blue[500]} 0%, ${theme.color.indigo[500]} 100%);
139
140
  }
140
141
  }
142
+ .image-uploads, .document-uploads {
143
+ display: flex;
144
+ flex-direction: row;
145
+ flex-wrap: nowrap;
146
+ gap: 8px;
147
+ list-style: none;
148
+ margin: 0 0 4px 0;
149
+ padding: 0;
150
+
151
+ img {
152
+ max-width: 240px;
153
+ max-height: 240px;
154
+ }
155
+ }
141
156
  }
142
157
 
143
158
  &.user {
144
159
  align-items: end;
145
160
  margin-left: 40px;
146
161
 
162
+ .chat-message-container {
163
+ display: flex;
164
+ flex-direction: column;
165
+ max-width: 100%;
166
+ align-items: end;
167
+ }
168
+
147
169
  .chat-message {
148
170
  display: flex;
149
171
  flex-direction: row;
@@ -8,15 +8,15 @@ import { FadingOverflow } from '../../components/FadingOverflow'
8
8
  import { useCurrentChat, useCurrentChatState } from '../../context/hooks'
9
9
  import { useMessageInputDictionary } from './dictionary'
10
10
 
11
- interface InfoBadgeProps {
11
+ interface ContextBadgeProps {
12
12
  label: string,
13
13
  color: ColorPaletteName,
14
14
  dismiss: string,
15
15
  onDismiss?: () => void,
16
16
  }
17
17
 
18
- const InfoBadge = ({ label, color, dismiss, onDismiss }: InfoBadgeProps) => (
19
- <Badge appearance="square" palette={color} className="info-badge"
18
+ const ContextBadge = ({ label, color, dismiss, onDismiss }: ContextBadgeProps) => (
19
+ <Badge appearance="square" palette={color} className="context-badge"
20
20
  afterElement={
21
21
  onDismiss &&
22
22
  <IconButton appearance="square" colorIcon={`${color}.800`} onClick={onDismiss} title={dismiss} arial-label={dismiss}>
@@ -34,7 +34,7 @@ const InfoBadge = ({ label, color, dismiss, onDismiss }: InfoBadgeProps) => (
34
34
  * - which stack is being used;
35
35
  * - which knowledge sources are being used.
36
36
  */
37
- export const InfoBar = () => {
37
+ export const ContextBar = () => {
38
38
  const t = useMessageInputDictionary()
39
39
  const chat = useCurrentChat()
40
40
  const currentStack = useCurrentChatState('stack')
@@ -47,7 +47,7 @@ export const InfoBar = () => {
47
47
  const onDismiss = features.knowledgeSource
48
48
  ? (() => chat.set('knowledgeSources', currentKnowledgeSources.filter(({ id }) => id !== ks.id)))
49
49
  : undefined
50
- return <li key={ks.id}><InfoBadge label={ks.label} dismiss={t.removeKS} color="teal" onDismiss={onDismiss} /></li>
50
+ return <li key={ks.id}><ContextBadge label={ks.label} dismiss={t.removeKS} color="teal" onDismiss={onDismiss} /></li>
51
51
  }), [currentKnowledgeSources])
52
52
  const shouldRenderRemoveAllButton = (
53
53
  currentSelection
@@ -69,7 +69,7 @@ export const InfoBar = () => {
69
69
  }, [])
70
70
 
71
71
  return (
72
- <div className={listToClass(['info-bar', visible && 'visible'])}>
72
+ <div className={listToClass(['info-bar', 'context-bar', visible && 'visible'])}>
73
73
  <div className="space"></div>
74
74
  <div className="content">
75
75
  {shouldRenderRemoveAllButton && (
@@ -83,12 +83,12 @@ export const InfoBar = () => {
83
83
  <ul>
84
84
  {currentSelection && (
85
85
  <li>
86
- <InfoBadge label={t.selected} dismiss={t.removeSelection} color="blue" onDismiss={removeCodeSelection} />
86
+ <ContextBadge label={t.selected} dismiss={t.removeSelection} color="blue" onDismiss={removeCodeSelection} />
87
87
  </li>
88
88
  )}
89
89
  {currentStack && (
90
90
  <li>
91
- <InfoBadge
91
+ <ContextBadge
92
92
  label={currentStack.label}
93
93
  dismiss={t.removeStack}
94
94
  color="cyan"
@@ -98,7 +98,7 @@ export const InfoBar = () => {
98
98
  )}
99
99
  {currentWorkspace && (
100
100
  <li>
101
- <InfoBadge
101
+ <ContextBadge
102
102
  label={currentWorkspace.label}
103
103
  dismiss={t.removeWorkspace}
104
104
  color="pink"
@@ -1,9 +1,10 @@
1
- import { KnowledgeSource, Plus, Spaces, Stack } from '@citric/icons'
1
+ import { DocumentUpload, KnowledgeSource, Plus, Spaces, Stack } from '@citric/icons'
2
2
  import { IconButton } from '@citric/ui'
3
3
  import { SelectionList } from '@stack-spot/portal-components/SelectionList'
4
- import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
5
4
  import { useMemo, useState } from 'react'
6
5
  import { useCurrentChatState, useWidget } from '../../context/hooks'
6
+ import { useUploadManager } from '../../utils/upload/context'
7
+ import { useMessageInputDictionary } from './dictionary'
7
8
 
8
9
  type chatFeatures = 'workspace' | 'knowledgeSource' | 'stack'
9
10
  type chatPanel = 'ks' | 'workspace' | 'stack'
@@ -12,8 +13,9 @@ export const SelectContent = () => {
12
13
  const widget = useWidget()
13
14
  const [visibleMenu, setVisibleMenu] = useState(false)
14
15
  const features = useCurrentChatState('features')
16
+ const t = useMessageInputDictionary()
17
+ const uploadManager = useUploadManager()
15
18
  const hasFeatureButtons = features.workspace || features.knowledgeSource || features.stack
16
- const t = useTranslate(dictionary)
17
19
 
18
20
  const itemConfigs = [
19
21
  {
@@ -59,31 +61,25 @@ export const SelectContent = () => {
59
61
  title={visibleMenu ? t.collapse : t.expand}
60
62
  data-test-hint="button-options"
61
63
  aria-label={visibleMenu ? t.collapse : t.expand}
64
+ aria-controls="chatMessageMenu"
62
65
  onClick={() => setVisibleMenu(state => !state)}>
63
66
  <Plus />
64
67
  </IconButton>
65
68
  <SelectionList
66
- style={{
67
- position: 'absolute',
68
- top: '-140px',
69
- }}
70
- id="menuConfig"
69
+ className="message-menu"
70
+ id="chatMessageMenu"
71
71
  visible={visibleMenu}
72
72
  onHide={() => setVisibleMenu(false)}
73
- items={listItems}
73
+ items={[
74
+ ...listItems,
75
+ {
76
+ label: t.upload,
77
+ onClick: () => uploadManager.open(),
78
+ className: 'upload-item',
79
+ icon: <DocumentUpload />,
80
+ },
81
+ ]}
74
82
  />
75
83
  </>
76
84
  )
77
85
  }
78
-
79
- const dictionary = {
80
- en: {
81
- expand: 'Expand options',
82
- collapse: 'Collapse options',
83
- },
84
- pt: {
85
- expand: 'Mostrar opções',
86
- collapse: 'Esconder opções',
87
- },
88
- } satisfies Dictionary
89
-
@@ -0,0 +1,69 @@
1
+ import { listToClass } from '@stack-spot/portal-theme'
2
+ import { interpolate } from '@stack-spot/portal-translate'
3
+ import { useMemo } from 'react'
4
+ import { FadingOverflow } from '../../components/FadingOverflow'
5
+ import { FileDescription } from '../../components/FileDescription'
6
+ import { useCurrentChat } from '../../context/hooks'
7
+ import { ChatEntry } from '../../state/ChatEntry'
8
+ import { useUploadErrorEffect, useUploadManager, useUploads, useUploadStatus } from '../../utils/upload/context'
9
+ import { FileIsTooLarge, MaxFilesReached } from '../../utils/upload/errors'
10
+ import { FileUpload } from '../../utils/upload/FileUpload'
11
+ import { useMessageInputDictionary } from './dictionary'
12
+
13
+ interface UploadedFileProps {
14
+ upload: FileUpload,
15
+ }
16
+
17
+ function createImageFromFile(file: File) {
18
+ return <img src={URL.createObjectURL(file)} alt={file.name} />
19
+ }
20
+
21
+ const UploadItem = ({ upload }: UploadedFileProps) => {
22
+ const uploadManager = useUploadManager()
23
+ const icon = upload.file.type.toLowerCase().startsWith('image/') ? createImageFromFile(upload.file) : undefined
24
+ const status = useUploadStatus(upload)
25
+ return <FileDescription
26
+ fileName={upload.file.name}
27
+ icon={icon}
28
+ onRemove={() => uploadManager.remove(upload)}
29
+ onRetry={() => upload.retry()}
30
+ status={status}
31
+ />
32
+ }
33
+
34
+ export const UploadBar = () => {
35
+ const uploads = useUploads()
36
+ const listItems = useMemo(() => uploads.map((up) => <li key={up.id}><UploadItem upload={up} /></li>), [uploads])
37
+ const t = useMessageInputDictionary()
38
+ const chat = useCurrentChat()
39
+ const visible = !!uploads.length
40
+
41
+ useUploadErrorEffect((errors) => {
42
+ const sizeErrors = errors.filter(e => e instanceof FileIsTooLarge)
43
+ const maxItemsErrors = errors.filter(e => e instanceof MaxFilesReached)
44
+ const maxSize = sizeErrors[0]?.maxSize
45
+ const maxItems = maxItemsErrors[0]?.maxFiles
46
+ const sizeErrorsNames = sizeErrors.map(e => e.fileName)
47
+ const maxItemsErrorsNames = maxItemsErrors.map(e => e.fileName)
48
+ const lines: string[] = []
49
+ if (sizeErrors.length) {
50
+ lines.push(`${interpolate(t.uploadSizeError, `${maxSize.value} ${maxSize.unit}`)}\n- ${sizeErrorsNames.join('\n- ')}`)
51
+ }
52
+ if (maxItemsErrors.length) {
53
+ lines.push(`${interpolate(t.uploadItemLimitError, maxItems)}\n- ${maxItemsErrorsNames.join('\n- ')}`)
54
+ }
55
+ if (!lines.length) return
56
+ chat.pushMessage(new ChatEntry({ agentType: 'system', type: 'md', content: lines.join('\n\n') }))
57
+ })
58
+
59
+ return (
60
+ <div className={listToClass(['info-bar', 'upload-bar', visible && 'visible'])}>
61
+ <div className="space"></div>
62
+ <div className="content">
63
+ <FadingOverflow className="list-overflow" scroll="arrows" enableHorizontalScrollWithVerticalWheel>
64
+ <ul>{listItems}</ul>
65
+ </FadingOverflow>
66
+ </div>
67
+ </div>
68
+ )
69
+ }
@@ -7,8 +7,6 @@ const dictionary = {
7
7
  spot: 'Select spot',
8
8
  knowledgeSource: 'Select knowledge sources',
9
9
  agent: 'Select agent',
10
- collapse: 'Hide buttons',
11
- expand: 'Show buttons',
12
10
  send: 'Send message',
13
11
  placeholder: 'Message to $0 or use / or @.',
14
12
  cancel: 'Cancel',
@@ -19,6 +17,14 @@ const dictionary = {
19
17
  selected: 'Selected',
20
18
  removeSelection: 'Remove current code selection',
21
19
  remove: 'Remove',
20
+ upload: 'Upload file',
21
+ expand: 'Expand options',
22
+ collapse: 'Collapse options',
23
+ uploadSizeError: 'The following files were not added to the upload list because they\'re larger than $1:',
24
+ uploadItemLimitError: 'The following files were not added because no more than $1 items may be uploaded at a time:',
25
+ cantSendBecauseOfUploadError: 'Can\'t send the message because one of the files in the upload list could not be uploaded. Please, retry it or remove it from the list.',
26
+ cantSendBecauseOfUploadProgress: 'Please wait until all files are uploaded before sending the message. You can also cancel the upload by removing it from the list of uploads.',
27
+ cantSendBecauseOfEmptyContent: 'You can\'t send empty messages. Please write some text or upload a file.',
22
28
  },
23
29
  pt: {
24
30
  stack: 'Selecionar stack',
@@ -26,8 +32,6 @@ const dictionary = {
26
32
  spot: 'Selecionar spot',
27
33
  knowledgeSource: 'Selecionar knowledge sources',
28
34
  agent: 'Selecionar agente',
29
- collapse: 'Esconder botões',
30
- expand: 'Mostrar botões',
31
35
  send: 'Enviar mensagem',
32
36
  placeholder: 'Mensagem para $0 ou use / ou @.',
33
37
  cancel: 'Cancelar',
@@ -38,6 +42,16 @@ const dictionary = {
38
42
  selected: 'Selecionado',
39
43
  removeSelection: 'Desfazer seleção de código',
40
44
  remove: 'Remover',
45
+ upload: 'Enviar arquivo',
46
+ expand: 'Mostrar opções',
47
+ collapse: 'Esconder opções',
48
+ uploadSizeError: 'Os seguintes arquivos não foram adicionados à lista de upload porque eles são maiores que $0:',
49
+ uploadItemLimitError: 'Os seguintes arquivos não foram adicionados à lista de upload porque é permitido enviar no máximo $0 arquivos por vez:',
50
+ uploadError: 'Ocorreu um erro ao enviar o arquivo "$0".',
51
+ unknownUploadError: 'Ocorreu um erro ao enviar os arquivos.',
52
+ cantSendBecauseOfUploadError: 'Não é possível enviar a mensagem, pois um dos arquivos na lista de uploads não pôde ser enviado. Por favor, tente enviá-lo novamente ou remova-o da lista.',
53
+ cantSendBecauseOfUploadProgress: 'Por favor aguarde todos os uploads de arquivos antes de enviar a mensagem. Você pode cancelar o upload removendo o arquivo da lista de uploads.',
54
+ cantSendBecauseOfEmptyContent: 'Não é possível enviar mensagens vazia. Por favor, escreva algum texto ou envie um arquivo.',
41
55
  },
42
56
  } satisfies Dictionary
43
57