@open-mercato/ui 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -1
- package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
- package/dist/ai/AiAssistantLauncher.js +596 -0
- package/dist/ai/AiAssistantLauncher.js.map +7 -0
- package/dist/ai/AiChat.js +1092 -0
- package/dist/ai/AiChat.js.map +7 -0
- package/dist/ai/AiChatSessions.js +297 -0
- package/dist/ai/AiChatSessions.js.map +7 -0
- package/dist/ai/AiDock.js +347 -0
- package/dist/ai/AiDock.js.map +7 -0
- package/dist/ai/AiMessageContent.js +369 -0
- package/dist/ai/AiMessageContent.js.map +7 -0
- package/dist/ai/ChatPaneTabs.js +251 -0
- package/dist/ai/ChatPaneTabs.js.map +7 -0
- package/dist/ai/index.js +115 -0
- package/dist/ai/index.js.map +7 -0
- package/dist/ai/parts/ConfirmationCard.js +211 -0
- package/dist/ai/parts/ConfirmationCard.js.map +7 -0
- package/dist/ai/parts/FieldDiffCard.js +119 -0
- package/dist/ai/parts/FieldDiffCard.js.map +7 -0
- package/dist/ai/parts/MutationPreviewCard.js +224 -0
- package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
- package/dist/ai/parts/MutationResultCard.js +240 -0
- package/dist/ai/parts/MutationResultCard.js.map +7 -0
- package/dist/ai/parts/approval-cards-map.js +15 -0
- package/dist/ai/parts/approval-cards-map.js.map +7 -0
- package/dist/ai/parts/index.js +24 -0
- package/dist/ai/parts/index.js.map +7 -0
- package/dist/ai/parts/pending-action-api.js +60 -0
- package/dist/ai/parts/pending-action-api.js.map +7 -0
- package/dist/ai/parts/types.js +1 -0
- package/dist/ai/parts/types.js.map +7 -0
- package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
- package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
- package/dist/ai/records/ActivityCard.js +83 -0
- package/dist/ai/records/ActivityCard.js.map +7 -0
- package/dist/ai/records/CompanyCard.js +81 -0
- package/dist/ai/records/CompanyCard.js.map +7 -0
- package/dist/ai/records/DealCard.js +76 -0
- package/dist/ai/records/DealCard.js.map +7 -0
- package/dist/ai/records/PersonCard.js +68 -0
- package/dist/ai/records/PersonCard.js.map +7 -0
- package/dist/ai/records/ProductCard.js +68 -0
- package/dist/ai/records/ProductCard.js.map +7 -0
- package/dist/ai/records/RecordCard.js +29 -0
- package/dist/ai/records/RecordCard.js.map +7 -0
- package/dist/ai/records/RecordCardShell.js +103 -0
- package/dist/ai/records/RecordCardShell.js.map +7 -0
- package/dist/ai/records/index.js +31 -0
- package/dist/ai/records/index.js.map +7 -0
- package/dist/ai/records/registry.js +51 -0
- package/dist/ai/records/registry.js.map +7 -0
- package/dist/ai/records/types.js +1 -0
- package/dist/ai/records/types.js.map +7 -0
- package/dist/ai/ui-part-registry.js +112 -0
- package/dist/ai/ui-part-registry.js.map +7 -0
- package/dist/ai/ui-part-slots.js +14 -0
- package/dist/ai/ui-part-slots.js.map +7 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
- package/dist/ai/upload-adapter.js +256 -0
- package/dist/ai/upload-adapter.js.map +7 -0
- package/dist/ai/useAiChat.js +549 -0
- package/dist/ai/useAiChat.js.map +7 -0
- package/dist/ai/useAiChatUpload.js +127 -0
- package/dist/ai/useAiChatUpload.js.map +7 -0
- package/dist/ai/useAiShortcuts.js +43 -0
- package/dist/ai/useAiShortcuts.js.map +7 -0
- package/dist/backend/AppShell.js +8 -4
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/BackendChromeProvider.js +2 -0
- package/dist/backend/BackendChromeProvider.js.map +2 -2
- package/dist/backend/DataTable.js +19 -2
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +19 -15
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +31 -3
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/injection/spotIds.js +6 -0
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/notifications/useNotificationEffect.js +38 -2
- package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
- package/dist/index.js +1 -0
- package/dist/index.js.map +2 -2
- package/jest.config.cjs +7 -1
- package/jest.markdown-mock.tsx +7 -0
- package/package.json +10 -4
- package/src/ai/AiAssistantLauncher.tsx +805 -0
- package/src/ai/AiChat.tsx +1483 -0
- package/src/ai/AiChatSessions.tsx +429 -0
- package/src/ai/AiDock.tsx +505 -0
- package/src/ai/AiMessageContent.tsx +515 -0
- package/src/ai/ChatPaneTabs.tsx +310 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
- package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
- package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
- package/src/ai/__tests__/AiChat.test.tsx +257 -0
- package/src/ai/__tests__/AiDock.test.tsx +124 -0
- package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
- package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
- package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
- package/src/ai/__tests__/upload-adapter.test.ts +213 -0
- package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
- package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
- package/src/ai/index.ts +125 -0
- package/src/ai/parts/ConfirmationCard.tsx +310 -0
- package/src/ai/parts/FieldDiffCard.tsx +173 -0
- package/src/ai/parts/MutationPreviewCard.tsx +302 -0
- package/src/ai/parts/MutationResultCard.tsx +360 -0
- package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
- package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
- package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
- package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
- package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
- package/src/ai/parts/approval-cards-map.ts +24 -0
- package/src/ai/parts/index.ts +27 -0
- package/src/ai/parts/pending-action-api.ts +123 -0
- package/src/ai/parts/types.ts +84 -0
- package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
- package/src/ai/records/ActivityCard.tsx +102 -0
- package/src/ai/records/CompanyCard.tsx +89 -0
- package/src/ai/records/DealCard.tsx +85 -0
- package/src/ai/records/PersonCard.tsx +77 -0
- package/src/ai/records/ProductCard.tsx +83 -0
- package/src/ai/records/RecordCard.tsx +37 -0
- package/src/ai/records/RecordCardShell.tsx +169 -0
- package/src/ai/records/index.ts +30 -0
- package/src/ai/records/registry.tsx +80 -0
- package/src/ai/records/types.ts +90 -0
- package/src/ai/ui-part-registry.ts +233 -0
- package/src/ai/ui-part-slots.ts +32 -0
- package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
- package/src/ai/upload-adapter.ts +421 -0
- package/src/ai/useAiChat.ts +865 -0
- package/src/ai/useAiChatUpload.ts +180 -0
- package/src/ai/useAiShortcuts.ts +79 -0
- package/src/backend/AppShell.tsx +12 -5
- package/src/backend/BackendChromeProvider.tsx +2 -0
- package/src/backend/DataTable.tsx +20 -1
- package/src/backend/FilterBar.tsx +26 -13
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
- package/src/backend/dashboard/DashboardScreen.tsx +38 -3
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
- package/src/backend/injection/spotIds.ts +6 -0
- package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
- package/src/backend/notifications/useNotificationEffect.ts +47 -2
- package/src/index.ts +1 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic upload adapter for the AI chat composer.
|
|
3
|
+
*
|
|
4
|
+
* Forwards files dropped into {@link AiChat} to the existing attachments API
|
|
5
|
+
* (`POST /api/attachments`, multipart form-data — see
|
|
6
|
+
* `packages/core/src/modules/attachments/api/route.ts`) and returns the
|
|
7
|
+
* resulting `attachmentIds` so the chat request layer can thread them into the
|
|
8
|
+
* dispatcher body (`POST /api/ai_assistant/ai/chat?agent=<id>` reads
|
|
9
|
+
* `attachmentIds` from JSON).
|
|
10
|
+
*
|
|
11
|
+
* The adapter is intentionally framework-agnostic: no Next.js imports, no
|
|
12
|
+
* React. A thin React hook ({@link useAiChatUpload}) wraps it for the composer.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DEFAULT_ATTACHMENTS_ENDPOINT = '/api/attachments'
|
|
16
|
+
const DEFAULT_AI_CHAT_ENTITY_ID = 'ai-chat-draft'
|
|
17
|
+
const DEFAULT_CONCURRENCY = 3
|
|
18
|
+
// Hard cap on a single upload's wall-clock time. The previous implementation
|
|
19
|
+
// had no timeout, so a stalled `/api/attachments` request would leave the
|
|
20
|
+
// composer chip spinning forever (the server never returned, the client
|
|
21
|
+
// never unblocked the Send button). 60s is generous for the documented
|
|
22
|
+
// per-file size limits and matches the behaviour of the rest of the
|
|
23
|
+
// backoffice's `apiCall` helpers.
|
|
24
|
+
const DEFAULT_PER_FILE_TIMEOUT_MS = 60_000
|
|
25
|
+
|
|
26
|
+
export type UploadFailureReason =
|
|
27
|
+
| 'mime_rejected'
|
|
28
|
+
| 'size_exceeded'
|
|
29
|
+
| 'network'
|
|
30
|
+
| 'server'
|
|
31
|
+
| 'aborted'
|
|
32
|
+
|
|
33
|
+
export interface UploadAttachmentsForChatOptions {
|
|
34
|
+
/** Optional override for the attachments endpoint (defaults to `/api/attachments`). */
|
|
35
|
+
endpoint?: string
|
|
36
|
+
/** Entity identifier recorded alongside the attachment. Defaults to `'ai-chat-draft'`. */
|
|
37
|
+
entityType?: string
|
|
38
|
+
/**
|
|
39
|
+
* Record identifier for the chat draft. When omitted, the adapter mints a
|
|
40
|
+
* per-invocation UUID so every batch groups cleanly in the attachments table.
|
|
41
|
+
*/
|
|
42
|
+
recordId?: string
|
|
43
|
+
/** Optional partition code; forwarded verbatim to the attachments route. */
|
|
44
|
+
partitionCode?: string
|
|
45
|
+
/** Optional injectable fetch (tests, portal). Defaults to `globalThis.fetch`. */
|
|
46
|
+
fetchImpl?: typeof fetch
|
|
47
|
+
/** Optional progress callback fired once per file completion. */
|
|
48
|
+
onProgress?: (
|
|
49
|
+
fileIndex: number,
|
|
50
|
+
progress: { loaded: number; total: number },
|
|
51
|
+
) => void
|
|
52
|
+
/** Abort the whole batch; queued files short-circuit as `'aborted'`. */
|
|
53
|
+
signal?: AbortSignal
|
|
54
|
+
/** Parallelism cap. Defaults to 3. */
|
|
55
|
+
concurrency?: number
|
|
56
|
+
/**
|
|
57
|
+
* Hard timeout per upload, in milliseconds. Defaults to 60_000 (60s).
|
|
58
|
+
* When the upload exceeds the timeout the request is aborted and the
|
|
59
|
+
* file lands in `failed` with `reason: 'aborted'` instead of the chip
|
|
60
|
+
* spinning forever. Pass `0` to disable.
|
|
61
|
+
*/
|
|
62
|
+
perFileTimeoutMs?: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface UploadedAttachment {
|
|
66
|
+
attachmentId: string
|
|
67
|
+
/**
|
|
68
|
+
* Server-returned (possibly sanitized) filename. The original
|
|
69
|
+
* client-side `File.name` is preserved on `originalFileName` so the
|
|
70
|
+
* chat composer can pair the upload result back to its chip without a
|
|
71
|
+
* sanitization-induced map miss.
|
|
72
|
+
*/
|
|
73
|
+
fileName: string
|
|
74
|
+
/** The exact `File.name` the caller passed in. Always set. */
|
|
75
|
+
originalFileName: string
|
|
76
|
+
/** Position of this file in the original input array. Always set. */
|
|
77
|
+
inputIndex: number
|
|
78
|
+
mediaType: string
|
|
79
|
+
size: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface UploadFailure {
|
|
83
|
+
fileName: string
|
|
84
|
+
/** The exact `File.name` the caller passed in. Always set. */
|
|
85
|
+
originalFileName: string
|
|
86
|
+
/** Position of this file in the original input array. Always set. */
|
|
87
|
+
inputIndex: number
|
|
88
|
+
reason: UploadFailureReason
|
|
89
|
+
message: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface UploadAttachmentsForChatResult {
|
|
93
|
+
items: UploadedAttachment[]
|
|
94
|
+
failed: UploadFailure[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function mintRecordId(): string {
|
|
98
|
+
const cryptoApi = (globalThis as unknown as { crypto?: Crypto }).crypto
|
|
99
|
+
if (cryptoApi && typeof cryptoApi.randomUUID === 'function') {
|
|
100
|
+
return cryptoApi.randomUUID()
|
|
101
|
+
}
|
|
102
|
+
const random = Math.random().toString(36).slice(2, 10)
|
|
103
|
+
const time = Date.now().toString(36)
|
|
104
|
+
return `ai-chat-${time}-${random}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveFetchImpl(explicit?: typeof fetch): typeof fetch {
|
|
108
|
+
if (explicit) return explicit
|
|
109
|
+
const fallback = (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch
|
|
110
|
+
if (!fallback) {
|
|
111
|
+
throw new Error('No fetch implementation available for uploadAttachmentsForChat')
|
|
112
|
+
}
|
|
113
|
+
return fallback.bind(globalThis) as typeof fetch
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeServerErrorMessage(raw: unknown): string {
|
|
117
|
+
if (raw && typeof raw === 'object') {
|
|
118
|
+
const err = (raw as { error?: unknown; message?: unknown }).error
|
|
119
|
+
if (typeof err === 'string' && err.trim()) return err
|
|
120
|
+
const msg = (raw as { message?: unknown }).message
|
|
121
|
+
if (typeof msg === 'string' && msg.trim()) return msg
|
|
122
|
+
}
|
|
123
|
+
return ''
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function mapStatusToReason(status: number, message: string): UploadFailureReason {
|
|
127
|
+
if (status === 413) return 'size_exceeded'
|
|
128
|
+
if (status === 403 || status === 415) return 'mime_rejected'
|
|
129
|
+
if (status === 400) {
|
|
130
|
+
const lower = message.toLowerCase()
|
|
131
|
+
if (lower.includes('file type') || lower.includes('active content')) {
|
|
132
|
+
return 'mime_rejected'
|
|
133
|
+
}
|
|
134
|
+
if (lower.includes('size') || lower.includes('quota')) {
|
|
135
|
+
return 'size_exceeded'
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return 'server'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseServerItem(
|
|
142
|
+
payload: unknown,
|
|
143
|
+
fallbackFile: File,
|
|
144
|
+
inputIndex: number,
|
|
145
|
+
): UploadedAttachment | null {
|
|
146
|
+
if (!payload || typeof payload !== 'object') return null
|
|
147
|
+
const item = (payload as { item?: unknown }).item
|
|
148
|
+
if (!item || typeof item !== 'object') return null
|
|
149
|
+
const id = (item as { id?: unknown }).id
|
|
150
|
+
if (typeof id !== 'string' || !id.trim()) return null
|
|
151
|
+
const fileName =
|
|
152
|
+
typeof (item as { fileName?: unknown }).fileName === 'string'
|
|
153
|
+
? (item as { fileName: string }).fileName
|
|
154
|
+
: fallbackFile.name
|
|
155
|
+
const fileSize = (item as { fileSize?: unknown }).fileSize
|
|
156
|
+
const size =
|
|
157
|
+
typeof fileSize === 'number' && Number.isFinite(fileSize) ? fileSize : fallbackFile.size
|
|
158
|
+
const mimeTypeCandidate = (item as { mimeType?: unknown; mediaType?: unknown })
|
|
159
|
+
const mediaType =
|
|
160
|
+
typeof mimeTypeCandidate.mimeType === 'string' && mimeTypeCandidate.mimeType.trim()
|
|
161
|
+
? mimeTypeCandidate.mimeType
|
|
162
|
+
: typeof mimeTypeCandidate.mediaType === 'string' && mimeTypeCandidate.mediaType.trim()
|
|
163
|
+
? mimeTypeCandidate.mediaType
|
|
164
|
+
: fallbackFile.type || 'application/octet-stream'
|
|
165
|
+
return {
|
|
166
|
+
attachmentId: id,
|
|
167
|
+
fileName,
|
|
168
|
+
originalFileName: fallbackFile.name,
|
|
169
|
+
inputIndex,
|
|
170
|
+
mediaType,
|
|
171
|
+
size,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface UploadSingleArgs {
|
|
176
|
+
file: File
|
|
177
|
+
fileIndex: number
|
|
178
|
+
endpoint: string
|
|
179
|
+
entityType: string
|
|
180
|
+
recordId: string
|
|
181
|
+
partitionCode?: string
|
|
182
|
+
fetchImpl: typeof fetch
|
|
183
|
+
signal: AbortSignal
|
|
184
|
+
perFileTimeoutMs: number
|
|
185
|
+
onProgress?: UploadAttachmentsForChatOptions['onProgress']
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
type SingleOutcome =
|
|
189
|
+
| { ok: true; item: UploadedAttachment }
|
|
190
|
+
| { ok: false; failure: UploadFailure }
|
|
191
|
+
|
|
192
|
+
async function uploadSingleFile(args: UploadSingleArgs): Promise<SingleOutcome> {
|
|
193
|
+
const {
|
|
194
|
+
file,
|
|
195
|
+
fileIndex,
|
|
196
|
+
endpoint,
|
|
197
|
+
entityType,
|
|
198
|
+
recordId,
|
|
199
|
+
partitionCode,
|
|
200
|
+
fetchImpl,
|
|
201
|
+
signal,
|
|
202
|
+
perFileTimeoutMs,
|
|
203
|
+
onProgress,
|
|
204
|
+
} = args
|
|
205
|
+
|
|
206
|
+
const buildFailure = (
|
|
207
|
+
reason: UploadFailureReason,
|
|
208
|
+
message: string,
|
|
209
|
+
): UploadFailure => ({
|
|
210
|
+
fileName: file.name,
|
|
211
|
+
originalFileName: file.name,
|
|
212
|
+
inputIndex: fileIndex,
|
|
213
|
+
reason,
|
|
214
|
+
message,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
if (signal.aborted) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
failure: buildFailure('aborted', 'Upload aborted before starting.'),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const form = new FormData()
|
|
225
|
+
form.append('entityId', entityType)
|
|
226
|
+
form.append('recordId', recordId)
|
|
227
|
+
form.append('file', file)
|
|
228
|
+
if (partitionCode && partitionCode.trim().length > 0) {
|
|
229
|
+
form.append('partitionCode', partitionCode.trim())
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Per-file timeout — wired through a child AbortController that is also
|
|
233
|
+
// cancelled when the parent batch aborts. Without this guard a stalled
|
|
234
|
+
// server (slow OCR, dead connection) would leave the chip spinning
|
|
235
|
+
// forever and block the composer's Send button indefinitely.
|
|
236
|
+
const localController = new AbortController()
|
|
237
|
+
const onParentAbort = () => localController.abort()
|
|
238
|
+
signal.addEventListener('abort', onParentAbort, { once: true })
|
|
239
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
|
|
240
|
+
let timedOut = false
|
|
241
|
+
if (perFileTimeoutMs > 0) {
|
|
242
|
+
timeoutHandle = setTimeout(() => {
|
|
243
|
+
timedOut = true
|
|
244
|
+
localController.abort()
|
|
245
|
+
}, perFileTimeoutMs)
|
|
246
|
+
}
|
|
247
|
+
const clearTimers = () => {
|
|
248
|
+
if (timeoutHandle !== null) {
|
|
249
|
+
clearTimeout(timeoutHandle)
|
|
250
|
+
timeoutHandle = null
|
|
251
|
+
}
|
|
252
|
+
signal.removeEventListener('abort', onParentAbort)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let response: Response
|
|
256
|
+
try {
|
|
257
|
+
response = await fetchImpl(endpoint, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
body: form,
|
|
260
|
+
signal: localController.signal,
|
|
261
|
+
})
|
|
262
|
+
} catch (networkError) {
|
|
263
|
+
clearTimers()
|
|
264
|
+
if (timedOut) {
|
|
265
|
+
return {
|
|
266
|
+
ok: false,
|
|
267
|
+
failure: buildFailure(
|
|
268
|
+
'aborted',
|
|
269
|
+
`Upload timed out after ${Math.round(perFileTimeoutMs / 1000)}s. The server did not respond — try again, or attach the file to a record first and reference it in the chat.`,
|
|
270
|
+
),
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const aborted =
|
|
274
|
+
signal.aborted ||
|
|
275
|
+
localController.signal.aborted ||
|
|
276
|
+
(networkError as { name?: string } | undefined)?.name === 'AbortError'
|
|
277
|
+
if (aborted) {
|
|
278
|
+
return { ok: false, failure: buildFailure('aborted', 'Upload aborted.') }
|
|
279
|
+
}
|
|
280
|
+
const message =
|
|
281
|
+
networkError instanceof Error ? networkError.message : 'Network request failed.'
|
|
282
|
+
return { ok: false, failure: buildFailure('network', message) }
|
|
283
|
+
}
|
|
284
|
+
clearTimers()
|
|
285
|
+
|
|
286
|
+
let payload: unknown = null
|
|
287
|
+
try {
|
|
288
|
+
const text = await response.text()
|
|
289
|
+
if (text && text.trim()) {
|
|
290
|
+
payload = JSON.parse(text) as unknown
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
// Response may not be JSON (HTML error page, empty body, etc.)
|
|
294
|
+
payload = null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!response.ok) {
|
|
298
|
+
const rawMessage = normalizeServerErrorMessage(payload)
|
|
299
|
+
const fallbackMessage = rawMessage || `Upload failed (${response.status}).`
|
|
300
|
+
const reason = mapStatusToReason(response.status, rawMessage)
|
|
301
|
+
return { ok: false, failure: buildFailure(reason, fallbackMessage) }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const item = parseServerItem(payload, file, fileIndex)
|
|
305
|
+
if (!item) {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
failure: buildFailure(
|
|
309
|
+
'server',
|
|
310
|
+
'Attachment API returned an unexpected response shape.',
|
|
311
|
+
),
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (onProgress) {
|
|
316
|
+
try {
|
|
317
|
+
onProgress(fileIndex, { loaded: item.size, total: item.size })
|
|
318
|
+
} catch {
|
|
319
|
+
// A misbehaving progress callback must never abort the upload pipeline.
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return { ok: true, item }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Uploads files in parallel (bounded to `concurrency`, default 3) via the
|
|
328
|
+
* attachments API and pairs the returned IDs back to the input order.
|
|
329
|
+
*
|
|
330
|
+
* The batch promise only rejects on programming errors — server rejections,
|
|
331
|
+
* network errors, and aborts are surfaced via {@link UploadAttachmentsForChatResult.failed}
|
|
332
|
+
* so the caller can render chips and retry UX without try/catch noise.
|
|
333
|
+
*/
|
|
334
|
+
export async function uploadAttachmentsForChat(
|
|
335
|
+
files: File[],
|
|
336
|
+
options: UploadAttachmentsForChatOptions = {},
|
|
337
|
+
): Promise<UploadAttachmentsForChatResult> {
|
|
338
|
+
const items: UploadedAttachment[] = []
|
|
339
|
+
const failed: UploadFailure[] = []
|
|
340
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
341
|
+
return { items, failed }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const fetchImpl = resolveFetchImpl(options.fetchImpl)
|
|
345
|
+
const endpoint = options.endpoint?.trim() || DEFAULT_ATTACHMENTS_ENDPOINT
|
|
346
|
+
const entityType = options.entityType?.trim() || DEFAULT_AI_CHAT_ENTITY_ID
|
|
347
|
+
const recordId = options.recordId?.trim() || mintRecordId()
|
|
348
|
+
const rawConcurrency = options.concurrency ?? DEFAULT_CONCURRENCY
|
|
349
|
+
const concurrency = Math.max(
|
|
350
|
+
1,
|
|
351
|
+
Math.min(files.length, Math.floor(rawConcurrency) || DEFAULT_CONCURRENCY),
|
|
352
|
+
)
|
|
353
|
+
const signal = options.signal ?? new AbortController().signal
|
|
354
|
+
const perFileTimeoutMs = (() => {
|
|
355
|
+
const raw = options.perFileTimeoutMs
|
|
356
|
+
if (raw === 0) return 0
|
|
357
|
+
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return raw
|
|
358
|
+
return DEFAULT_PER_FILE_TIMEOUT_MS
|
|
359
|
+
})()
|
|
360
|
+
|
|
361
|
+
const outcomes: Array<SingleOutcome | null> = new Array(files.length).fill(null)
|
|
362
|
+
let nextIndex = 0
|
|
363
|
+
|
|
364
|
+
const worker = async (): Promise<void> => {
|
|
365
|
+
while (true) {
|
|
366
|
+
if (signal.aborted) return
|
|
367
|
+
const currentIndex = nextIndex
|
|
368
|
+
if (currentIndex >= files.length) return
|
|
369
|
+
nextIndex = currentIndex + 1
|
|
370
|
+
const file = files[currentIndex]
|
|
371
|
+
outcomes[currentIndex] = await uploadSingleFile({
|
|
372
|
+
file,
|
|
373
|
+
fileIndex: currentIndex,
|
|
374
|
+
endpoint,
|
|
375
|
+
entityType,
|
|
376
|
+
recordId,
|
|
377
|
+
partitionCode: options.partitionCode,
|
|
378
|
+
fetchImpl,
|
|
379
|
+
signal,
|
|
380
|
+
perFileTimeoutMs,
|
|
381
|
+
onProgress: options.onProgress,
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const workerCount = Math.min(concurrency, files.length)
|
|
387
|
+
const workers = Array.from({ length: workerCount }, () => worker())
|
|
388
|
+
await Promise.all(workers)
|
|
389
|
+
|
|
390
|
+
for (let index = 0; index < files.length; index += 1) {
|
|
391
|
+
const outcome = outcomes[index]
|
|
392
|
+
if (outcome && outcome.ok) {
|
|
393
|
+
items.push(outcome.item)
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
396
|
+
if (outcome && !outcome.ok) {
|
|
397
|
+
failed.push(outcome.failure)
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
// Worker exited without processing this slot → it was skipped due to abort.
|
|
401
|
+
const file = files[index]
|
|
402
|
+
const fallbackName = file?.name ?? `file-${index}`
|
|
403
|
+
failed.push({
|
|
404
|
+
fileName: fallbackName,
|
|
405
|
+
originalFileName: fallbackName,
|
|
406
|
+
inputIndex: index,
|
|
407
|
+
reason: 'aborted',
|
|
408
|
+
message: 'Upload aborted before starting.',
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return { items, failed }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export const __testables = {
|
|
416
|
+
DEFAULT_ATTACHMENTS_ENDPOINT,
|
|
417
|
+
DEFAULT_AI_CHAT_ENTITY_ID,
|
|
418
|
+
DEFAULT_CONCURRENCY,
|
|
419
|
+
DEFAULT_PER_FILE_TIMEOUT_MS,
|
|
420
|
+
mapStatusToReason,
|
|
421
|
+
}
|