@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
@@ -0,0 +1,114 @@
1
+ import { IconBox, Text } from '@citric/core'
2
+ import { Document, Sync, TimesMini } from '@citric/icons'
3
+ import { IconButton, LoadingCircular } from '@citric/ui'
4
+ import { theme } from '@stack-spot/portal-theme'
5
+ import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
6
+ import { styled } from 'styled-components'
7
+ import { FileUploadStatus } from '../utils/upload/types'
8
+
9
+ export interface FileDescriptionProps {
10
+ fileName: string,
11
+ icon?: React.ReactNode,
12
+ status?: FileUploadStatus,
13
+ onRemove?: () => void,
14
+ onRetry?: () => void,
15
+ }
16
+
17
+ const Styled = styled.div`
18
+ padding: 4px;
19
+ border-radius: 4px;
20
+ display: flex;
21
+ gap: 8px;
22
+ background-color: ${theme.color.light[600]};
23
+ box-sizing: border-box;
24
+ &.error {
25
+ border: 1px solid ${theme.color.danger[500]};
26
+ }
27
+ &.pending .status {
28
+ cursor: progress;
29
+ }
30
+ .image {
31
+ border-radius: 4px;
32
+ overflow: hidden;
33
+ background-color: ${theme.color.light[500]};
34
+ width: 28px;
35
+ aspect-ratio: 1;
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: center;
39
+ position: relative;
40
+ img {
41
+ width: 100%;
42
+ }
43
+ .status {
44
+ position: absolute;
45
+ background-color: rgba(0, 0, 0, 0.3);
46
+ top: 0;
47
+ left: 0;
48
+ bottom: 0;
49
+ right: 0;
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ button {
54
+ padding: 2px;
55
+ }
56
+ }
57
+ }
58
+ .details {
59
+ display: flex;
60
+ flex-direction: column;
61
+ justify-content: space-between;
62
+ small {
63
+ max-width: 100px;
64
+ white-space: nowrap;
65
+ text-overflow: ellipsis;
66
+ overflow: hidden;
67
+ line-height: 1;
68
+ }
69
+ }
70
+ button {
71
+ padding: 0;
72
+ margin: -2px -2px 0 0;
73
+ }
74
+ `
75
+
76
+ export const FileDescription = ({ fileName, icon, status, onRemove, onRetry }: FileDescriptionProps) => {
77
+ const t = useTranslate(dictionary)
78
+ const [, name, extension] = fileName.match(/(.+)\.([^.]+)$/) ?? ['', 'Unknown', 'Unknown']
79
+ return (
80
+ <Styled className={status} aria-busy={status === 'pending'}>
81
+ <div className="image">
82
+ {icon ?? <IconBox><Document /></IconBox>}
83
+ {status === 'pending' && <div className="status" aria-label={t.loading}>
84
+ <LoadingCircular size="xs" />
85
+ </div>}
86
+ {status === 'error' && <div className="status" aria-label={t.error}>
87
+ <IconButton onClick={onRetry}><Sync /></IconButton>
88
+ </div>}
89
+ </div>
90
+ <div className="details">
91
+ <Text appearance="microtext1" className="name" title={name}>{name}</Text>
92
+ <Text appearance="microtext1" colorScheme="light.700">{extension.toUpperCase()}</Text>
93
+ </div>
94
+ {onRemove && <IconButton onClick={onRemove} title={t.remove} arial-label={`${t.remove} ${name}`}>
95
+ <TimesMini />
96
+ </IconButton>}
97
+ </Styled>
98
+ )
99
+ }
100
+
101
+ const dictionary = {
102
+ en: {
103
+ loading: 'Uploading file',
104
+ retry: 'Retry',
105
+ remove: 'Remove',
106
+ error: 'Error while uploading file',
107
+ },
108
+ pt: {
109
+ loading: 'Carregando arquivo',
110
+ retry: 'Tentar novamente',
111
+ remove: 'Remover',
112
+ error: 'Erro ao carregar o arquivo',
113
+ },
114
+ } satisfies Dictionary
@@ -65,6 +65,12 @@ export interface ChatTool {
65
65
  description: string,
66
66
  }
67
67
 
68
+ export interface UploadedFile {
69
+ id: string,
70
+ name: string,
71
+ image?: string,
72
+ }
73
+
68
74
  export interface TextChatEntry {
69
75
  /**
70
76
  * "text" for simple unformatted paragraphs. "md" for markdown.
@@ -165,6 +171,10 @@ export interface TextChatEntry {
165
171
  * Valid for input types.
166
172
  */
167
173
  initialValue?: string[],
174
+ /**
175
+ * If this chat entry contains any uploaded file, these are the files being uploaded along the message.
176
+ */
177
+ upload?: UploadedFile[],
168
178
  }
169
179
 
170
180
  type ChatEntryListener = (value: TextChatEntry) => void
@@ -2,7 +2,9 @@ import { dropRight, last, pull } from 'lodash'
2
2
  import { ulid } from 'ulid'
3
3
  import { AbortedError } from '../AbortedError'
4
4
  import { ChatFeatures, getFeaturesWithDefaults } from '../features'
5
+ import { UploadManager } from '../utils/upload/UploadManager'
5
6
  import { ChatEntry } from './ChatEntry'
7
+ import { acceptedFileTypes, maxFileSize, maxUploadItems } from './constants'
6
8
  import { ObservableState } from './ObservableState'
7
9
  import { Labeled, LabeledAgent } from './types'
8
10
 
@@ -124,6 +126,10 @@ export class ChatState extends ObservableState<ChatProperties> {
124
126
  * Abort signals currently active.
125
127
  */
126
128
  private abortions: AbortController[] = []
129
+ /**
130
+ * Used to manage uploads for this Chat.
131
+ */
132
+ readonly uploadManager = new UploadManager({ maxSize: maxFileSize, accept: acceptedFileTypes, maxItems: maxUploadItems })
127
133
  untitled: boolean
128
134
 
129
135
  constructor({ id, initial, entries = [], interceptors = [], untitled = false }: Options) {
@@ -0,0 +1,12 @@
1
+ import { FileSize } from './types'
2
+
3
+ export const acceptedFileTypes = [
4
+ 'json', 'yaml', 'txt', 'md', 'json', 'yaml', 'pdf', 'xls', 'xlsx', 'csv', 'cbl', 'cpp', 'cxx', 'cc', 'c', 'hpp', 'hxx', 'hh', 'h', 'cs',
5
+ 'go', 'html', 'htm', 'kt', 'kts', 'md', 'php', 'proto', 'py', 'java', 'js', 'jsx', 'ts', 'tsx', 'rst', 'rb', 'rs', 'scala', 'swift',
6
+ 'sql', 'yaml', 'yml', 'tf', 'sh', 'ps1', 'psd1', 'psm1', 'bat', 'cmd', 'rego', 'f', 'for', 'r', 'pl', 'vb', 'dart', 'hs', 'lua',
7
+ 'asm', 'groovy', 'gvy', 'gy', 'mat', 'clj', 'lisp', 'm', 'cls', 'css', 'scss', 'json', 'jpg', 'jpeg', 'png',
8
+ ]
9
+
10
+ export const maxFileSize: FileSize = { value: 10, unit: 'MB' }
11
+
12
+ export const maxUploadItems = 5
@@ -12,3 +12,8 @@ export interface LabeledAgent extends LabeledWithImage {
12
12
  slug?: string,
13
13
  visibility_level?: string,
14
14
  }
15
+
16
+ export interface FileSize {
17
+ value: number,
18
+ unit: 'B' | 'KB' | 'MB' | 'GB',
19
+ }
package/src/utils/chat.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { FixedChatRequest } from '@stack-spot/portal-network'
2
2
  import appData from '../app-metadata.json'
3
+ import { ChatEntry } from '../state/ChatEntry'
3
4
  import { ChatState } from '../state/ChatState'
4
5
  import { defaultLanguage } from './programming-languages'
5
6
 
@@ -11,13 +12,14 @@ import { defaultLanguage } from './programming-languages'
11
12
  * @param state the ChatState to build the context from.
12
13
  * @returns the conversation context ready to be sent to the backend.
13
14
  */
14
- export function buildConversationContext(state: ChatState): FixedChatRequest['context'] {
15
+ export function buildConversationContext(state: ChatState, message?: ChatEntry): FixedChatRequest['context'] {
15
16
  return {
16
17
  workspace: state.get('workspace')?.id,
17
18
  conversation_id: state.id,
18
19
  stack_id: state.get('stack')?.id,
19
20
  language: state.get('codeLanguage') || (state.get('codeSelection') ? defaultLanguage : undefined),
20
21
  knowledge_sources: state.get('knowledgeSources')?.map(ks => ks.id),
22
+ upload_ids: message?.getValue().upload?.map(f => f.id),
21
23
  agent_id: state.get('agent')?.id,
22
24
  agent_built_in: state.get('agent')?.builtIn,
23
25
  os: navigator.userAgent,
@@ -0,0 +1,64 @@
1
+ import { dataIntegrationClient } from '@stack-spot/portal-network'
2
+ import { FileUploadType } from '@stack-spot/portal-network/api/dataIntegration'
3
+ import { pull } from 'lodash'
4
+ import { FileUploadStatus } from './types'
5
+ import { getFileId } from './utils'
6
+
7
+ export type FileUploadChangeListener = (status: FileUploadStatus) => void
8
+
9
+ export class FileUpload {
10
+ readonly file: File
11
+ status: FileUploadStatus = 'pending'
12
+ readonly id: string
13
+ readonly type: FileUploadType
14
+ error?: any
15
+ uploadId?: string
16
+ private listeners: FileUploadChangeListener[] = []
17
+ private abortController = new AbortController()
18
+
19
+ constructor(file: File, type: FileUploadType = 'CONTEXT') {
20
+ this.file = file
21
+ this.id = getFileId(file)
22
+ this.type = type
23
+ this.upload()
24
+ }
25
+
26
+ private runListeners() {
27
+ this.listeners.forEach(l => l(this.status))
28
+ }
29
+
30
+ private async upload() {
31
+ try {
32
+ const [id] = await dataIntegrationClient.uploadFiles.mutate({ files: [this.file], type: this.type }, this.abortController.signal)
33
+ this.uploadId = id
34
+ this.status = 'success'
35
+ } catch (error) {
36
+ this.error = error
37
+ this.status = 'error'
38
+ }
39
+ this.runListeners()
40
+ }
41
+
42
+ stop() {
43
+ this.abortController.abort()
44
+ }
45
+
46
+ retry() {
47
+ if (this.status != 'error') return
48
+ this.status = 'pending'
49
+ this.runListeners()
50
+ this.upload()
51
+ }
52
+
53
+ onChange(listener: FileUploadChangeListener) {
54
+ this.listeners.push(listener)
55
+ return () => {
56
+ pull(this.listeners, listener)
57
+ }
58
+ }
59
+
60
+ destroy() {
61
+ this.listeners = []
62
+ this.stop()
63
+ }
64
+ }
@@ -0,0 +1,147 @@
1
+ import { pull } from 'lodash'
2
+ import { FileSize } from '../../state/types'
3
+ import { FileAlreadyExists, FileIsTooLarge, MaxFilesReached, UploadError } from './errors'
4
+ import { FileUpload } from './FileUpload'
5
+ import { getFileId, unitPower } from './utils'
6
+
7
+ export type UploadChangeListener = (files: FileUpload[]) => void
8
+ export type UploadStatusChangeListener = (status: UploadManagerStatus) => void
9
+ export type UploadErrorListener = (errors: UploadError[]) => void
10
+ export type UploadManagerStatus = 'idle' | 'uploading' | 'error'
11
+
12
+ export class UploadManager {
13
+ private changeListeners: UploadChangeListener[] = []
14
+ private statusListeners: UploadStatusChangeListener[] = []
15
+ private errorListeners: UploadErrorListener[] = []
16
+ private input: HTMLInputElement | null = null
17
+ private value: FileUpload[] = []
18
+ private maxSize?: FileSize
19
+ private maxItems: number
20
+ private accept: string[]
21
+ private onDestroy: () => void = () => {}
22
+ status: UploadManagerStatus = 'idle'
23
+
24
+ constructor(options?: { maxSize?: FileSize, accept?: string[], maxItems?: number }) {
25
+ this.maxSize = options?.maxSize
26
+ this.accept = options?.accept ?? []
27
+ this.maxItems = options?.maxItems ?? 0
28
+ }
29
+
30
+ private runChangeListeners() {
31
+ this.changeListeners.forEach(l => l(this.value))
32
+ }
33
+
34
+ private runStatusListeners() {
35
+ const status = this.computeStatus()
36
+ if (status === this.status) return
37
+ this.status = status
38
+ this.statusListeners.forEach(l => l(status))
39
+ }
40
+
41
+ private hasEquivalentFile(file: File) {
42
+ const id = getFileId(file)
43
+ return this.value.some(f => id === f.id)
44
+ }
45
+
46
+ private attachEventListeners() {
47
+ const handleChange = () => {
48
+ const newFiles: FileUpload[] = []
49
+ const errors: UploadError[] = []
50
+ for (const f of this.input?.files ?? []) {
51
+ if (this.maxSize && f.size > this.maxSize.value * Math.pow(1024, unitPower[this.maxSize?.unit])) {
52
+ errors.push(new FileIsTooLarge(f.name, this.maxSize))
53
+ } else if (this.maxItems && this.value.length + newFiles.length === this.maxItems) {
54
+ errors.push(new MaxFilesReached(f.name, this.maxItems))
55
+ } else if (this.hasEquivalentFile(f)) {
56
+ errors.push(new FileAlreadyExists(f.name))
57
+ } else {
58
+ const upload = new FileUpload(f)
59
+ newFiles.push(upload)
60
+ upload.onChange(() => this.runStatusListeners())
61
+ }
62
+ }
63
+ if (newFiles.length) {
64
+ this.value = [...this.value, ...newFiles]
65
+ this.runChangeListeners()
66
+ this.runStatusListeners()
67
+ }
68
+ if (errors.length) {
69
+ this.errorListeners.forEach(l => l(errors))
70
+ }
71
+ this.destroy()
72
+ }
73
+ this.onDestroy = () => this.input?.removeEventListener('change', handleChange)
74
+ this.input?.addEventListener('change', handleChange)
75
+ }
76
+
77
+ private createInput() {
78
+ this.input = document.createElement('input')
79
+ this.input.setAttribute('type', 'file')
80
+ this.input.setAttribute('multiple', '')
81
+ this.input.setAttribute('style', 'display: none')
82
+ this.input.setAttribute('accept', `.${this.accept.join(',.')}`)
83
+ document.body.append(this.input)
84
+ this.attachEventListeners()
85
+ }
86
+
87
+ private computeStatus(): UploadManagerStatus {
88
+ let uploading = false
89
+ for (const f of this.value) {
90
+ if (f.status === 'error') return 'error'
91
+ if (f.status === 'pending') uploading = true
92
+ }
93
+ return uploading ? 'uploading' : 'idle'
94
+ }
95
+
96
+ open() {
97
+ this.createInput()
98
+ this.input?.click()
99
+ }
100
+
101
+ get(): FileUpload[] {
102
+ return this.value
103
+ }
104
+
105
+ reset() {
106
+ this.value.forEach(f => f.destroy())
107
+ this.value = []
108
+ this.runChangeListeners()
109
+ }
110
+
111
+ remove(file: FileUpload) {
112
+ file.destroy()
113
+ this.value = this.value.filter(f => f !== file)
114
+ this.runChangeListeners()
115
+ this.runStatusListeners()
116
+ }
117
+
118
+ onChange(listener: UploadChangeListener) {
119
+ this.changeListeners.push(listener)
120
+ return () => {
121
+ pull(this.changeListeners, listener)
122
+ }
123
+ }
124
+
125
+ onChangeStatus(listener: UploadStatusChangeListener) {
126
+ this.statusListeners.push(listener)
127
+ return () => {
128
+ pull(this.statusListeners, listener)
129
+ }
130
+ }
131
+
132
+ onError(listener: UploadErrorListener) {
133
+ this.errorListeners.push(listener)
134
+ return () => {
135
+ pull(this.errorListeners, listener)
136
+ }
137
+ }
138
+
139
+ isFull() {
140
+ return !!this.maxItems && this.value.length === this.maxItems
141
+ }
142
+
143
+ destroy() {
144
+ this.onDestroy()
145
+ this.input?.remove()
146
+ }
147
+ }
@@ -0,0 +1,44 @@
1
+ import { useEffectOnce } from '@stack-spot/portal-components'
2
+ import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
3
+ import { UploadError } from './errors'
4
+ import { FileUpload } from './FileUpload'
5
+ import { FileUploadStatus } from './types'
6
+ import { UploadManager, UploadManagerStatus } from './UploadManager'
7
+
8
+ const context = createContext<UploadManager | undefined>(undefined)
9
+
10
+ export const UploadProvider = ({ value = new UploadManager(), children }: { value: UploadManager, children: ReactNode }) => {
11
+ useEffectOnce(() => () => value.destroy())
12
+ return <context.Provider value={value}>{children}</context.Provider>
13
+ }
14
+
15
+ export function useUploadManager() {
16
+ const manager = useContext(context)
17
+ if (!manager) throw new Error('"useUploadManager()" must be used only within the context of an <UploadProvider>.')
18
+ return manager
19
+ }
20
+
21
+ export function useUploads(): FileUpload[] {
22
+ const manager = useUploadManager()
23
+ const [files, setFiles] = useState<FileUpload[]>([])
24
+ useEffect(() => manager.onChange(setFiles))
25
+ return files
26
+ }
27
+
28
+ export function useUploadErrorEffect(effect: (errors: UploadError[]) => void) {
29
+ const manager = useUploadManager()
30
+ useEffect(() => manager.onError(effect))
31
+ }
32
+
33
+ export function useUploadManagerStatus() {
34
+ const manager = useUploadManager()
35
+ const [status, setStatus] = useState<UploadManagerStatus>(manager.status)
36
+ useEffect(() => manager.onChangeStatus(setStatus))
37
+ return status
38
+ }
39
+
40
+ export function useUploadStatus(file: FileUpload) {
41
+ const [status, setStatus] = useState<FileUploadStatus>(file.status)
42
+ useEffect(() => file.onChange(setStatus))
43
+ return status
44
+ }
@@ -0,0 +1,34 @@
1
+ import { FileSize } from '../../state/types'
2
+
3
+ export class UploadError extends Error {
4
+ readonly fileName: string
5
+
6
+ constructor(fileName: string, message?: string) {
7
+ super(message || `There was an error while uploading ${fileName}`)
8
+ this.fileName = fileName
9
+ }
10
+ }
11
+
12
+ export class FileIsTooLarge extends UploadError {
13
+ readonly maxSize: FileSize
14
+
15
+ constructor(fileName: string, maxSize: FileSize) {
16
+ super(fileName, `Can't upload ${fileName} because it exceeds the maximum file size of ${maxSize.value} ${maxSize.unit}.`)
17
+ this.maxSize = maxSize
18
+ }
19
+ }
20
+
21
+ export class FileAlreadyExists extends UploadError {
22
+ constructor(fileName: string) {
23
+ super(fileName, `Can't upload ${fileName} because it is already on the list.`)
24
+ }
25
+ }
26
+
27
+ export class MaxFilesReached extends UploadError {
28
+ readonly maxFiles: number
29
+
30
+ constructor(fileName: string, maxFiles: number) {
31
+ super(fileName, `Can't upload ${fileName} because the maximum number of files (${maxFiles}) has been reached.`)
32
+ this.maxFiles = maxFiles
33
+ }
34
+ }
@@ -0,0 +1,7 @@
1
+ export interface FileDescription {
2
+ id: string,
3
+ name: string,
4
+ extension: string,
5
+ }
6
+
7
+ export type FileUploadStatus = 'pending' | 'success' | 'error'
@@ -0,0 +1,12 @@
1
+ import { FileSize } from '../../state/types'
2
+
3
+ export function getFileId(file: File) {
4
+ return `${file.name}|${file.lastModified}|${file.size}`
5
+ }
6
+
7
+ export const unitPower: Record<FileSize['unit'], number> = {
8
+ B: 0,
9
+ KB: 1,
10
+ MB: 2,
11
+ GB: 3,
12
+ }
@@ -4,16 +4,17 @@ import { LabeledWithImage } from '../../state/types'
4
4
 
5
5
  interface Props {
6
6
  agent?: LabeledWithImage,
7
+ icon?: React.ReactElement,
7
8
  }
8
9
 
9
10
  /**
10
11
  * Renders the avatar of an agent in a message.
11
12
  */
12
- export const AgentInfo = ({ agent }: Props) => (
13
+ export const AgentInfo = ({ agent, icon }: Props) => (
13
14
  <>
14
15
  {agent?.image
15
16
  ? <img src={agent.image} className="custom-agent-image" />
16
- : <IconBox className="default-image-wrapper" colorIcon="light.700"><Agent className="agent-image" /></IconBox>
17
+ : <IconBox className="default-image-wrapper" colorIcon="light.700">{icon ?? <Agent className="agent-image" />}</IconBox>
17
18
  }
18
19
  <Text appearance="body2">{agent?.label}</Text>
19
20
  </>