@open-mercato/ui 0.6.2-develop.3446.1.bd060c6017 → 0.6.2-develop.3467.1.2a1818709d
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/dist/ai/AiChat.js +130 -22
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiChatSessions.js +73 -3
- package/dist/ai/AiChatSessions.js.map +2 -2
- package/dist/ai/conversation-store.js +147 -0
- package/dist/ai/conversation-store.js.map +7 -0
- package/dist/ai/useAiChat.js +80 -4
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/AuthSessionGuard.js +99 -0
- package/dist/backend/AuthSessionGuard.js.map +7 -0
- package/package.json +3 -3
- package/src/ai/AiChat.tsx +138 -22
- package/src/ai/AiChatSessions.tsx +89 -9
- package/src/ai/__tests__/AiChat.conversation.test.tsx +86 -0
- package/src/ai/conversation-store.ts +244 -0
- package/src/ai/useAiChat.ts +97 -8
- package/src/backend/AuthSessionGuard.tsx +114 -0
- package/src/backend/__tests__/AuthSessionGuard.test.tsx +150 -0
package/src/ai/AiChat.tsx
CHANGED
|
@@ -16,12 +16,14 @@ import {
|
|
|
16
16
|
CircleDashed,
|
|
17
17
|
Copy,
|
|
18
18
|
Database,
|
|
19
|
+
Download,
|
|
19
20
|
FileQuestion,
|
|
20
21
|
GitBranch,
|
|
21
22
|
Handshake,
|
|
22
23
|
Lightbulb,
|
|
23
24
|
ListChecks,
|
|
24
25
|
Loader2,
|
|
26
|
+
Maximize2,
|
|
25
27
|
Paperclip,
|
|
26
28
|
Plus,
|
|
27
29
|
Search,
|
|
@@ -38,6 +40,13 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
|
38
40
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
39
41
|
import { Alert, AlertDescription, AlertTitle } from '../primitives/alert'
|
|
40
42
|
import { Button } from '../primitives/button'
|
|
43
|
+
import {
|
|
44
|
+
Dialog,
|
|
45
|
+
DialogContent,
|
|
46
|
+
DialogDescription,
|
|
47
|
+
DialogHeader,
|
|
48
|
+
DialogTitle,
|
|
49
|
+
} from '../primitives/dialog'
|
|
41
50
|
import { IconButton } from '../primitives/icon-button'
|
|
42
51
|
import { Label } from '../primitives/label'
|
|
43
52
|
import { Textarea } from '../primitives/textarea'
|
|
@@ -638,6 +647,128 @@ function ReasoningPanel({ text, streaming }: { text: string; streaming: boolean
|
|
|
638
647
|
)
|
|
639
648
|
}
|
|
640
649
|
|
|
650
|
+
function getAttachmentImageUrl(file: AiChatMessageFile): string | null {
|
|
651
|
+
if (file.id) return `/api/attachments/image/${encodeURIComponent(file.id)}`
|
|
652
|
+
return file.previewUrl ?? null
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function getAttachmentDownloadUrl(file: AiChatMessageFile): string | null {
|
|
656
|
+
if (file.id) return `/api/attachments/file/${encodeURIComponent(file.id)}?download=1`
|
|
657
|
+
return file.previewUrl ?? null
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function MessageFileAttachment({ file }: { file: AiChatMessageFile }) {
|
|
661
|
+
const t = useT()
|
|
662
|
+
const [zoomOpen, setZoomOpen] = React.useState(false)
|
|
663
|
+
const imageUrl = file.previewUrl ? getAttachmentImageUrl(file) : null
|
|
664
|
+
const downloadUrl = getAttachmentDownloadUrl(file)
|
|
665
|
+
const downloadLabel = t('ai_assistant.chat.downloadFile', 'Download {name}').replace(
|
|
666
|
+
'{name}',
|
|
667
|
+
file.name,
|
|
668
|
+
)
|
|
669
|
+
const zoomLabel = t('ai_assistant.chat.zoomImage', 'Open {name} preview').replace(
|
|
670
|
+
'{name}',
|
|
671
|
+
file.name,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
if (!file.previewUrl) {
|
|
675
|
+
return (
|
|
676
|
+
<span className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-xs">
|
|
677
|
+
<Paperclip className="size-3 shrink-0" aria-hidden />
|
|
678
|
+
<span className="max-w-[180px] truncate">{file.name}</span>
|
|
679
|
+
{downloadUrl ? (
|
|
680
|
+
<IconButton
|
|
681
|
+
asChild
|
|
682
|
+
variant="ghost"
|
|
683
|
+
size="xs"
|
|
684
|
+
className="-mr-1 size-5 shrink-0"
|
|
685
|
+
>
|
|
686
|
+
<a href={downloadUrl} download={file.name} aria-label={downloadLabel}>
|
|
687
|
+
<Download className="size-3" aria-hidden />
|
|
688
|
+
</a>
|
|
689
|
+
</IconButton>
|
|
690
|
+
) : null}
|
|
691
|
+
</span>
|
|
692
|
+
)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return (
|
|
696
|
+
<>
|
|
697
|
+
<div className="group/file relative max-w-full">
|
|
698
|
+
<button
|
|
699
|
+
type="button"
|
|
700
|
+
className="block max-w-full rounded-md border border-border bg-muted/30 focus-visible:outline-none focus-visible:shadow-focus"
|
|
701
|
+
onClick={() => setZoomOpen(true)}
|
|
702
|
+
aria-label={zoomLabel}
|
|
703
|
+
data-ai-chat-image-preview=""
|
|
704
|
+
>
|
|
705
|
+
<img
|
|
706
|
+
src={file.previewUrl}
|
|
707
|
+
alt={file.name}
|
|
708
|
+
className="max-h-32 max-w-[200px] rounded-md object-cover"
|
|
709
|
+
/>
|
|
710
|
+
<span className="pointer-events-none absolute right-1 top-1 inline-flex size-6 items-center justify-center rounded-md bg-background/90 text-muted-foreground opacity-0 shadow-xs transition-opacity group-hover/file:opacity-100 group-focus-within/file:opacity-100">
|
|
711
|
+
<Maximize2 className="size-3.5" aria-hidden />
|
|
712
|
+
</span>
|
|
713
|
+
</button>
|
|
714
|
+
{downloadUrl ? (
|
|
715
|
+
<IconButton
|
|
716
|
+
asChild
|
|
717
|
+
variant="white"
|
|
718
|
+
size="xs"
|
|
719
|
+
className="absolute bottom-1 right-1 opacity-0 shadow-xs transition-opacity group-hover/file:opacity-100 group-focus-within/file:opacity-100"
|
|
720
|
+
>
|
|
721
|
+
<a
|
|
722
|
+
href={downloadUrl}
|
|
723
|
+
download={file.name}
|
|
724
|
+
aria-label={downloadLabel}
|
|
725
|
+
onClick={(event) => event.stopPropagation()}
|
|
726
|
+
data-ai-chat-file-download=""
|
|
727
|
+
>
|
|
728
|
+
<Download className="size-3.5" aria-hidden />
|
|
729
|
+
</a>
|
|
730
|
+
</IconButton>
|
|
731
|
+
) : null}
|
|
732
|
+
</div>
|
|
733
|
+
|
|
734
|
+
<Dialog open={zoomOpen} onOpenChange={setZoomOpen}>
|
|
735
|
+
<DialogContent className="gap-3 p-4 sm:max-w-[min(96vw,960px)]">
|
|
736
|
+
<DialogHeader className="min-w-0 pr-16">
|
|
737
|
+
<DialogTitle className="truncate text-sm">{file.name}</DialogTitle>
|
|
738
|
+
<DialogDescription className="sr-only">
|
|
739
|
+
{t('ai_assistant.chat.imagePreviewDialogDescription', 'Image preview')}
|
|
740
|
+
</DialogDescription>
|
|
741
|
+
</DialogHeader>
|
|
742
|
+
<div className="relative flex min-h-0 items-center justify-center overflow-auto rounded-md bg-muted/30 p-2">
|
|
743
|
+
<img
|
|
744
|
+
src={imageUrl ?? file.previewUrl}
|
|
745
|
+
alt={file.name}
|
|
746
|
+
className="max-h-[75vh] max-w-full object-contain"
|
|
747
|
+
/>
|
|
748
|
+
{downloadUrl ? (
|
|
749
|
+
<IconButton
|
|
750
|
+
asChild
|
|
751
|
+
variant="white"
|
|
752
|
+
size="sm"
|
|
753
|
+
className="absolute bottom-3 right-3 shadow-xs"
|
|
754
|
+
>
|
|
755
|
+
<a
|
|
756
|
+
href={downloadUrl}
|
|
757
|
+
download={file.name}
|
|
758
|
+
aria-label={downloadLabel}
|
|
759
|
+
data-ai-chat-file-download=""
|
|
760
|
+
>
|
|
761
|
+
<Download className="size-4" aria-hidden />
|
|
762
|
+
</a>
|
|
763
|
+
</IconButton>
|
|
764
|
+
) : null}
|
|
765
|
+
</div>
|
|
766
|
+
</DialogContent>
|
|
767
|
+
</Dialog>
|
|
768
|
+
</>
|
|
769
|
+
)
|
|
770
|
+
}
|
|
771
|
+
|
|
641
772
|
function MessageRow({
|
|
642
773
|
message,
|
|
643
774
|
registry,
|
|
@@ -737,24 +868,9 @@ function MessageRow({
|
|
|
737
868
|
</div>
|
|
738
869
|
{message.files && message.files.length > 0 ? (
|
|
739
870
|
<div className="flex flex-wrap gap-2 py-1">
|
|
740
|
-
{message.files.map((file, i) =>
|
|
741
|
-
file.
|
|
742
|
-
|
|
743
|
-
key={i}
|
|
744
|
-
src={file.previewUrl}
|
|
745
|
-
alt={file.name}
|
|
746
|
-
className="max-h-32 max-w-[200px] rounded-md border border-border object-cover"
|
|
747
|
-
/>
|
|
748
|
-
) : (
|
|
749
|
-
<span
|
|
750
|
-
key={i}
|
|
751
|
-
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-xs"
|
|
752
|
-
>
|
|
753
|
-
<Paperclip className="size-3" aria-hidden />
|
|
754
|
-
{file.name}
|
|
755
|
-
</span>
|
|
756
|
-
),
|
|
757
|
-
)}
|
|
871
|
+
{message.files.map((file, i) => (
|
|
872
|
+
<MessageFileAttachment key={`${file.id ?? file.name}:${i}`} file={file} />
|
|
873
|
+
))}
|
|
758
874
|
</div>
|
|
759
875
|
) : null}
|
|
760
876
|
{isAssistant && message.reasoning ? (
|
|
@@ -1030,10 +1146,9 @@ export function AiChat({
|
|
|
1030
1146
|
attachmentId?: string
|
|
1031
1147
|
/**
|
|
1032
1148
|
* Base64 data URL of the image (capped by PREVIEW_DATA_URL_MAX_BYTES).
|
|
1033
|
-
* Stored on the message
|
|
1034
|
-
* server
|
|
1035
|
-
*
|
|
1036
|
-
* the chat doesn't need.
|
|
1149
|
+
* Stored on the local message for immediate previews; once the transcript
|
|
1150
|
+
* is loaded from server storage the attachment id is used to rebuild a
|
|
1151
|
+
* same-origin thumbnail URL.
|
|
1037
1152
|
*/
|
|
1038
1153
|
previewDataUrl?: string
|
|
1039
1154
|
error?: string
|
|
@@ -1255,6 +1370,7 @@ export function AiChat({
|
|
|
1255
1370
|
const isImage = entry.file.type.startsWith('image/')
|
|
1256
1371
|
const fallback = isImage ? URL.createObjectURL(entry.file) : undefined
|
|
1257
1372
|
return {
|
|
1373
|
+
id: entry.attachmentId,
|
|
1258
1374
|
name: entry.file.name,
|
|
1259
1375
|
type: entry.file.type,
|
|
1260
1376
|
previewUrl: isImage ? (entry.previewDataUrl ?? fallback) : undefined,
|
|
@@ -4,12 +4,10 @@
|
|
|
4
4
|
* Multi-tab AI chat sessions.
|
|
5
5
|
*
|
|
6
6
|
* Each agent can have several concurrent conversation threads (sessions).
|
|
7
|
-
* The provider
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* persistence slot keyed by `(agent, conversationId)`, which is why every
|
|
12
|
-
* session gets its own UUID even when it shares an agent with the next tab.
|
|
7
|
+
* The provider mirrors session metadata from the server-side conversation
|
|
8
|
+
* store. localStorage remains a backward-compatible cache so existing browser
|
|
9
|
+
* sessions can be imported rather than orphaned, but the server is the source
|
|
10
|
+
* of truth whenever it is reachable.
|
|
13
11
|
*
|
|
14
12
|
* Sessions are partitioned into:
|
|
15
13
|
* - `open` → currently shown in the tab strip
|
|
@@ -22,6 +20,12 @@
|
|
|
22
20
|
*/
|
|
23
21
|
|
|
24
22
|
import * as React from 'react'
|
|
23
|
+
import {
|
|
24
|
+
createAiServerConversation,
|
|
25
|
+
listAiServerConversations,
|
|
26
|
+
updateAiServerConversation,
|
|
27
|
+
type AiServerConversation,
|
|
28
|
+
} from './conversation-store'
|
|
25
29
|
|
|
26
30
|
const STORAGE_KEY = 'om-ai-chat-sessions-v1'
|
|
27
31
|
const HISTORY_LIMIT = 50
|
|
@@ -135,6 +139,49 @@ function writePersisted(state: AiChatSessionsState): void {
|
|
|
135
139
|
}
|
|
136
140
|
}
|
|
137
141
|
|
|
142
|
+
function serverConversationToSession(
|
|
143
|
+
conversation: AiServerConversation,
|
|
144
|
+
existing?: AiChatSession,
|
|
145
|
+
): AiChatSession {
|
|
146
|
+
const createdAt = Date.parse(conversation.createdAt)
|
|
147
|
+
const updatedAt = Date.parse(conversation.lastMessageAt ?? conversation.updatedAt)
|
|
148
|
+
return {
|
|
149
|
+
id: existing?.id ?? conversation.conversationId,
|
|
150
|
+
agentId: conversation.agentId,
|
|
151
|
+
conversationId: conversation.conversationId,
|
|
152
|
+
name: conversation.title ?? existing?.name,
|
|
153
|
+
createdAt: Number.isFinite(createdAt) ? createdAt : existing?.createdAt ?? Date.now(),
|
|
154
|
+
lastUsedAt: Number.isFinite(updatedAt) ? updatedAt : existing?.lastUsedAt ?? Date.now(),
|
|
155
|
+
status: conversation.status,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function mergeServerConversations(
|
|
160
|
+
prev: AiChatSessionsState,
|
|
161
|
+
conversations: AiServerConversation[],
|
|
162
|
+
): AiChatSessionsState {
|
|
163
|
+
const byConversationId = new Map(prev.sessions.map((session) => [session.conversationId, session]))
|
|
164
|
+
const serverSessions = conversations.map((conversation) =>
|
|
165
|
+
serverConversationToSession(conversation, byConversationId.get(conversation.conversationId)),
|
|
166
|
+
)
|
|
167
|
+
const serverIds = new Set(serverSessions.map((session) => session.conversationId))
|
|
168
|
+
const localOnly = prev.sessions.filter((session) => !serverIds.has(session.conversationId))
|
|
169
|
+
const sessions = [...serverSessions, ...localOnly]
|
|
170
|
+
const activeByAgent = { ...prev.activeByAgent }
|
|
171
|
+
const agentIds = new Set(sessions.map((session) => session.agentId))
|
|
172
|
+
for (const agentId of agentIds) {
|
|
173
|
+
const activeId = activeByAgent[agentId]
|
|
174
|
+
const active = sessions.find((session) => session.id === activeId)
|
|
175
|
+
if (active && active.status === 'open') continue
|
|
176
|
+
const nextOpen = sessions
|
|
177
|
+
.filter((session) => session.agentId === agentId && session.status === 'open')
|
|
178
|
+
.sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0]
|
|
179
|
+
if (nextOpen) activeByAgent[agentId] = nextOpen.id
|
|
180
|
+
else delete activeByAgent[agentId]
|
|
181
|
+
}
|
|
182
|
+
return { sessions, activeByAgent }
|
|
183
|
+
}
|
|
184
|
+
|
|
138
185
|
export function AiChatSessionsProvider({ children }: { children: React.ReactNode }) {
|
|
139
186
|
// Hydrate synchronously via a lazy initializer. The previous "empty
|
|
140
187
|
// state + post-mount load effect" pattern had a window where the
|
|
@@ -153,6 +200,17 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
|
|
|
153
200
|
writePersisted(state)
|
|
154
201
|
}, [state])
|
|
155
202
|
|
|
203
|
+
React.useEffect(() => {
|
|
204
|
+
let cancelled = false
|
|
205
|
+
void listAiServerConversations({ limit: 100 }).then((conversations) => {
|
|
206
|
+
if (cancelled || !conversations) return
|
|
207
|
+
setState((prev) => mergeServerConversations(prev, conversations))
|
|
208
|
+
})
|
|
209
|
+
return () => {
|
|
210
|
+
cancelled = true
|
|
211
|
+
}
|
|
212
|
+
}, [])
|
|
213
|
+
|
|
156
214
|
const update = React.useCallback(
|
|
157
215
|
(mutator: (prev: AiChatSessionsState) => AiChatSessionsState) => {
|
|
158
216
|
setState((prev) => mutator(prev))
|
|
@@ -197,6 +255,10 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
|
|
|
197
255
|
lastUsedAt: Date.now(),
|
|
198
256
|
status: 'open',
|
|
199
257
|
}
|
|
258
|
+
void createAiServerConversation({
|
|
259
|
+
agentId: session.agentId,
|
|
260
|
+
conversationId: session.conversationId,
|
|
261
|
+
})
|
|
200
262
|
update((prev) => ({
|
|
201
263
|
sessions: [...prev.sessions, session],
|
|
202
264
|
activeByAgent: { ...prev.activeByAgent, [agentId]: session.id },
|
|
@@ -206,6 +268,10 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
|
|
|
206
268
|
|
|
207
269
|
const closeSession = React.useCallback(
|
|
208
270
|
(sessionId: string) => {
|
|
271
|
+
const serverTarget = state.sessions.find((s) => s.id === sessionId)
|
|
272
|
+
if (serverTarget) {
|
|
273
|
+
void updateAiServerConversation(serverTarget.conversationId, { status: 'closed' })
|
|
274
|
+
}
|
|
209
275
|
update((prev) => {
|
|
210
276
|
const target = prev.sessions.find((s) => s.id === sessionId)
|
|
211
277
|
if (!target) return prev
|
|
@@ -227,11 +293,15 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
|
|
|
227
293
|
return { sessions, activeByAgent }
|
|
228
294
|
})
|
|
229
295
|
},
|
|
230
|
-
[update],
|
|
296
|
+
[state.sessions, update],
|
|
231
297
|
)
|
|
232
298
|
|
|
233
299
|
const reopenSession = React.useCallback(
|
|
234
300
|
(sessionId: string) => {
|
|
301
|
+
const serverTarget = state.sessions.find((s) => s.id === sessionId)
|
|
302
|
+
if (serverTarget) {
|
|
303
|
+
void updateAiServerConversation(serverTarget.conversationId, { status: 'open' })
|
|
304
|
+
}
|
|
235
305
|
update((prev) => {
|
|
236
306
|
const target = prev.sessions.find((s) => s.id === sessionId)
|
|
237
307
|
if (!target) return prev
|
|
@@ -244,7 +314,7 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
|
|
|
244
314
|
}
|
|
245
315
|
})
|
|
246
316
|
},
|
|
247
|
-
[update],
|
|
317
|
+
[state.sessions, update],
|
|
248
318
|
)
|
|
249
319
|
|
|
250
320
|
const setActiveSession = React.useCallback(
|
|
@@ -267,6 +337,12 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
|
|
|
267
337
|
const renameSession = React.useCallback(
|
|
268
338
|
(sessionId: string, name: string) => {
|
|
269
339
|
const trimmed = name.trim()
|
|
340
|
+
const serverTarget = state.sessions.find((s) => s.id === sessionId)
|
|
341
|
+
if (serverTarget) {
|
|
342
|
+
void updateAiServerConversation(serverTarget.conversationId, {
|
|
343
|
+
title: trimmed.length > 0 ? trimmed : null,
|
|
344
|
+
})
|
|
345
|
+
}
|
|
270
346
|
update((prev) => ({
|
|
271
347
|
...prev,
|
|
272
348
|
sessions: prev.sessions.map((s) =>
|
|
@@ -276,7 +352,7 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
|
|
|
276
352
|
),
|
|
277
353
|
}))
|
|
278
354
|
},
|
|
279
|
-
[update],
|
|
355
|
+
[state.sessions, update],
|
|
280
356
|
)
|
|
281
357
|
|
|
282
358
|
const touchSession = React.useCallback(
|
|
@@ -334,6 +410,10 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
|
|
|
334
410
|
lastUsedAt: Date.now(),
|
|
335
411
|
status: 'open',
|
|
336
412
|
}
|
|
413
|
+
void createAiServerConversation({
|
|
414
|
+
agentId: mintedSession.agentId,
|
|
415
|
+
conversationId: mintedSession.conversationId,
|
|
416
|
+
})
|
|
337
417
|
}
|
|
338
418
|
resolved = mintedSession
|
|
339
419
|
return {
|
|
@@ -157,6 +157,92 @@ describe('<AiChat> conversationId threading (Step 5.15)', () => {
|
|
|
157
157
|
const parsedBody = JSON.parse(init.body as string)
|
|
158
158
|
expect(parsedBody.conversationId).toBe('conv-body-xyz')
|
|
159
159
|
})
|
|
160
|
+
|
|
161
|
+
it('hydrates the transcript from server storage for an existing conversation', async () => {
|
|
162
|
+
const originalFetch = globalThis.fetch
|
|
163
|
+
const fetchMock = jest.fn(async () => ({
|
|
164
|
+
ok: true,
|
|
165
|
+
status: 200,
|
|
166
|
+
json: async () => ({
|
|
167
|
+
conversation: {
|
|
168
|
+
conversationId: 'conv-server-xyz',
|
|
169
|
+
agentId: 'customers.account_assistant',
|
|
170
|
+
title: null,
|
|
171
|
+
status: 'open',
|
|
172
|
+
visibility: 'private',
|
|
173
|
+
pageContext: null,
|
|
174
|
+
createdAt: '2026-05-18T10:00:00.000Z',
|
|
175
|
+
updatedAt: '2026-05-18T10:00:00.000Z',
|
|
176
|
+
lastMessageAt: '2026-05-18T10:00:00.000Z',
|
|
177
|
+
importedFromLocalAt: null,
|
|
178
|
+
},
|
|
179
|
+
messages: [
|
|
180
|
+
{
|
|
181
|
+
id: 'server-msg-1',
|
|
182
|
+
clientMessageId: 'client-msg-1',
|
|
183
|
+
role: 'user',
|
|
184
|
+
content: 'Server question',
|
|
185
|
+
uiParts: [],
|
|
186
|
+
attachmentIds: ['att-image-1'],
|
|
187
|
+
files: [
|
|
188
|
+
{
|
|
189
|
+
name: 'IMG_5328.JPEG',
|
|
190
|
+
mimeType: 'image/jpeg',
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
model: null,
|
|
194
|
+
metadata: null,
|
|
195
|
+
createdAt: '2026-05-18T10:00:00.000Z',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: 'server-msg-2',
|
|
199
|
+
clientMessageId: 'client-msg-2',
|
|
200
|
+
role: 'assistant',
|
|
201
|
+
content: 'Server answer',
|
|
202
|
+
uiParts: [],
|
|
203
|
+
attachmentIds: [],
|
|
204
|
+
files: [],
|
|
205
|
+
model: null,
|
|
206
|
+
metadata: null,
|
|
207
|
+
createdAt: '2026-05-18T10:00:01.000Z',
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
nextCursor: null,
|
|
211
|
+
}),
|
|
212
|
+
})) as unknown as typeof fetch
|
|
213
|
+
globalThis.fetch = fetchMock
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
renderWithProviders(
|
|
217
|
+
<AiChat agent="customers.account_assistant" conversationId="conv-server-xyz" />,
|
|
218
|
+
{ dict },
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
expect(await screen.findByText('Server question')).toBeInTheDocument()
|
|
222
|
+
const image = await screen.findByRole('img', { name: 'IMG_5328.JPEG' })
|
|
223
|
+
expect(image).toHaveAttribute(
|
|
224
|
+
'src',
|
|
225
|
+
'/api/attachments/image/att-image-1?width=320&height=320&cropType=contain',
|
|
226
|
+
)
|
|
227
|
+
const download = screen.getByLabelText('Download IMG_5328.JPEG')
|
|
228
|
+
expect(download).toHaveAttribute(
|
|
229
|
+
'href',
|
|
230
|
+
'/api/attachments/file/att-image-1?download=1',
|
|
231
|
+
)
|
|
232
|
+
fireEvent.click(image)
|
|
233
|
+
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
|
234
|
+
expect(screen.getByRole('heading', { name: 'IMG_5328.JPEG' })).toBeInTheDocument()
|
|
235
|
+
const images = screen.getAllByRole('img', { name: 'IMG_5328.JPEG' })
|
|
236
|
+
expect(images[1]).toHaveAttribute('src', '/api/attachments/image/att-image-1')
|
|
237
|
+
expect(await screen.findByText('Server answer')).toBeInTheDocument()
|
|
238
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
239
|
+
'/api/ai_assistant/ai/conversations/conv-server-xyz?limit=100',
|
|
240
|
+
expect.objectContaining({ credentials: 'include' }),
|
|
241
|
+
)
|
|
242
|
+
} finally {
|
|
243
|
+
globalThis.fetch = originalFetch
|
|
244
|
+
}
|
|
245
|
+
})
|
|
160
246
|
})
|
|
161
247
|
|
|
162
248
|
// <AiChat> mounts useAgentModels (Phase 4b) which hits /api/ai_assistant/ai/agents/<id>/models
|