@stack-spot/ai-chat-widget 1.24.4 → 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.
- package/CHANGELOG.md +7 -0
- package/dist/app-metadata.json +3 -3
- package/dist/chat-interceptors/send-message.js +3 -3
- package/dist/chat-interceptors/send-message.js.map +1 -1
- package/dist/components/FileDescription.d.ts +10 -0
- package/dist/components/FileDescription.d.ts.map +1 -0
- package/dist/components/FileDescription.js +85 -0
- package/dist/components/FileDescription.js.map +1 -0
- package/dist/state/ChatEntry.d.ts +9 -0
- package/dist/state/ChatEntry.d.ts.map +1 -1
- package/dist/state/ChatEntry.js.map +1 -1
- package/dist/state/ChatState.d.ts +5 -0
- package/dist/state/ChatState.d.ts.map +1 -1
- package/dist/state/ChatState.js +6 -0
- package/dist/state/ChatState.js.map +1 -1
- package/dist/state/constants.d.ts +5 -0
- package/dist/state/constants.d.ts.map +1 -0
- package/dist/state/constants.js +9 -0
- package/dist/state/constants.js.map +1 -0
- package/dist/state/types.d.ts +4 -0
- package/dist/state/types.d.ts.map +1 -1
- package/dist/utils/chat.d.ts +2 -1
- package/dist/utils/chat.d.ts.map +1 -1
- package/dist/utils/chat.js +2 -1
- package/dist/utils/chat.js.map +1 -1
- package/dist/utils/upload/FileUpload.d.ts +21 -0
- package/dist/utils/upload/FileUpload.d.ts.map +1 -0
- package/dist/utils/upload/FileUpload.js +55 -0
- package/dist/utils/upload/FileUpload.js.map +1 -0
- package/dist/utils/upload/UploadManager.d.ts +40 -0
- package/dist/utils/upload/UploadManager.d.ts.map +1 -0
- package/dist/utils/upload/UploadManager.js +131 -0
- package/dist/utils/upload/UploadManager.js.map +1 -0
- package/dist/utils/upload/context.d.ts +15 -0
- package/dist/utils/upload/context.d.ts.map +1 -0
- package/dist/utils/upload/context.js +37 -0
- package/dist/utils/upload/context.js.map +1 -0
- package/dist/utils/upload/errors.d.ts +17 -0
- package/dist/utils/upload/errors.d.ts.map +1 -0
- package/dist/utils/upload/errors.js +27 -0
- package/dist/utils/upload/errors.js.map +1 -0
- package/dist/utils/upload/types.d.ts +7 -0
- package/dist/utils/upload/types.d.ts.map +1 -0
- package/dist/utils/upload/types.js +2 -0
- package/dist/utils/upload/types.js.map +1 -0
- package/dist/utils/upload/utils.d.ts +4 -0
- package/dist/utils/upload/utils.d.ts.map +1 -0
- package/dist/utils/upload/utils.js +10 -0
- package/dist/utils/upload/utils.js.map +1 -0
- package/dist/views/Chat/AgentInfo.d.ts +2 -1
- package/dist/views/Chat/AgentInfo.d.ts.map +1 -1
- package/dist/views/Chat/AgentInfo.js +2 -2
- package/dist/views/Chat/AgentInfo.js.map +1 -1
- package/dist/views/Chat/ChatMessage.d.ts +1 -1
- package/dist/views/Chat/ChatMessage.d.ts.map +1 -1
- package/dist/views/Chat/ChatMessage.js +24 -5
- package/dist/views/Chat/ChatMessage.js.map +1 -1
- package/dist/views/Chat/styled.d.ts.map +1 -1
- package/dist/views/Chat/styled.js +15 -1
- package/dist/views/Chat/styled.js.map +1 -1
- package/dist/views/MessageInput/{InfoBar.d.ts → ContextBar.d.ts} +2 -2
- package/dist/views/MessageInput/ContextBar.d.ts.map +1 -0
- package/dist/views/MessageInput/{InfoBar.js → ContextBar.js} +5 -5
- package/dist/views/MessageInput/ContextBar.js.map +1 -0
- package/dist/views/MessageInput/SelectContent.d.ts.map +1 -1
- package/dist/views/MessageInput/SelectContent.js +14 -17
- package/dist/views/MessageInput/SelectContent.js.map +1 -1
- package/dist/views/MessageInput/UploadBar.d.ts +2 -0
- package/dist/views/MessageInput/UploadBar.d.ts.map +1 -0
- package/dist/views/MessageInput/UploadBar.js +47 -0
- package/dist/views/MessageInput/UploadBar.js.map +1 -0
- package/dist/views/MessageInput/dictionary.d.ts +1 -1
- package/dist/views/MessageInput/dictionary.d.ts.map +1 -1
- package/dist/views/MessageInput/dictionary.js +18 -4
- package/dist/views/MessageInput/dictionary.js.map +1 -1
- package/dist/views/MessageInput/index.d.ts.map +1 -1
- package/dist/views/MessageInput/index.js +46 -5
- package/dist/views/MessageInput/index.js.map +1 -1
- package/dist/views/MessageInput/styled.d.ts.map +1 -1
- package/dist/views/MessageInput/styled.js +56 -27
- package/dist/views/MessageInput/styled.js.map +1 -1
- package/dist/views/Steps/dictionary.d.ts +1 -1
- package/package.json +2 -2
- package/src/app-metadata.json +3 -3
- package/src/chat-interceptors/send-message.ts +3 -3
- package/src/components/FileDescription.tsx +114 -0
- package/src/state/ChatEntry.ts +10 -0
- package/src/state/ChatState.ts +6 -0
- package/src/state/constants.ts +12 -0
- package/src/state/types.ts +5 -0
- package/src/utils/chat.ts +3 -1
- package/src/utils/upload/FileUpload.ts +64 -0
- package/src/utils/upload/UploadManager.ts +147 -0
- package/src/utils/upload/context.tsx +44 -0
- package/src/utils/upload/errors.ts +34 -0
- package/src/utils/upload/types.ts +7 -0
- package/src/utils/upload/utils.ts +12 -0
- package/src/views/Chat/AgentInfo.tsx +3 -2
- package/src/views/Chat/ChatMessage.tsx +48 -13
- package/src/views/Chat/styled.ts +15 -1
- package/src/views/MessageInput/{InfoBar.tsx → ContextBar.tsx} +9 -9
- package/src/views/MessageInput/SelectContent.tsx +17 -21
- package/src/views/MessageInput/UploadBar.tsx +69 -0
- package/src/views/MessageInput/dictionary.ts +18 -4
- package/src/views/MessageInput/index.tsx +77 -32
- package/src/views/MessageInput/styled.ts +56 -27
- package/dist/views/MessageInput/InfoBar.d.ts.map +0 -1
- 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
|
package/src/state/ChatEntry.ts
CHANGED
|
@@ -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
|
package/src/state/ChatState.ts
CHANGED
|
@@ -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
|
package/src/state/types.ts
CHANGED
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,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"
|
|
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
|
</>
|