@open-mercato/ui 0.6.2-develop.3461.1.605f31c2c9 → 0.6.2
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 +149 -0
- package/dist/ai/conversation-store.js.map +7 -0
- package/dist/ai/parts/MutationPreviewCard.js +4 -3
- package/dist/ai/parts/MutationPreviewCard.js.map +2 -2
- package/dist/ai/parts/MutationResultCard.js +9 -16
- package/dist/ai/parts/MutationResultCard.js.map +2 -2
- 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/dist/backend/CrudForm.js +2 -1
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/FilterOverlay.js +1 -1
- package/dist/backend/FilterOverlay.js.map +1 -1
- package/dist/backend/dashboard/widgetRegistry.js +2 -1
- package/dist/backend/dashboard/widgetRegistry.js.map +2 -2
- package/dist/backend/date-range/dateRanges.js +16 -18
- package/dist/backend/date-range/dateRanges.js.map +2 -2
- package/dist/backend/detail/AttachmentMetadataDialog.js +3 -1
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +1 -1
- package/dist/backend/detail/InlineEditors.js.map +1 -1
- package/dist/backend/injection/useAppEvent.js +2 -7
- package/dist/backend/injection/useAppEvent.js.map +2 -2
- package/dist/backend/injection/widgetRegistry.js +2 -1
- package/dist/backend/injection/widgetRegistry.js.map +2 -2
- package/dist/backend/messages/useMessageCompose.js +62 -5
- package/dist/backend/messages/useMessageCompose.js.map +2 -2
- package/dist/backend/messages/useMessageComposeOperations.js +64 -5
- package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
- package/dist/backend/schedule/ScheduleCalendar.js +11 -1
- package/dist/backend/schedule/ScheduleCalendar.js.map +2 -2
- package/dist/backend/schedule/ScheduleToolbar.js +11 -1
- package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
- package/dist/primitives/calendar.js +1 -1
- package/dist/primitives/calendar.js.map +1 -1
- package/dist/primitives/date-format.js +1 -1
- package/dist/primitives/date-format.js.map +1 -1
- package/dist/primitives/date-picker-helpers.js +7 -9
- package/dist/primitives/date-picker-helpers.js.map +2 -2
- package/dist/primitives/date-picker.js +1 -1
- package/dist/primitives/date-picker.js.map +2 -2
- package/dist/primitives/date-range-picker.js +1 -1
- package/dist/primitives/date-range-picker.js.map +2 -2
- package/jest.config.cjs +4 -1
- package/package.json +6 -7
- package/src/ai/AiChat.tsx +138 -22
- package/src/ai/AiChatSessions.tsx +89 -9
- package/src/ai/__tests__/AiAssistantLauncher.test.tsx +1 -1
- package/src/ai/__tests__/AiChat.conversation.test.tsx +86 -0
- package/src/ai/__tests__/AiChat.test.tsx +17 -2
- package/src/ai/__tests__/conversation-store.test.ts +29 -0
- package/src/ai/conversation-store.ts +246 -0
- package/src/ai/parts/MutationPreviewCard.tsx +25 -20
- package/src/ai/parts/MutationResultCard.tsx +15 -27
- package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +26 -1
- package/src/ai/parts/__tests__/MutationResultCard.test.tsx +16 -1
- package/src/ai/useAiChat.ts +97 -8
- package/src/backend/AuthSessionGuard.tsx +114 -0
- package/src/backend/CrudForm.tsx +3 -2
- package/src/backend/FilterOverlay.tsx +1 -1
- package/src/backend/__tests__/AppShell.test.tsx +1 -1
- package/src/backend/__tests__/AuthSessionGuard.test.tsx +150 -0
- package/src/backend/__tests__/SidebarCustomizationEditor.test.tsx +8 -1
- package/src/backend/dashboard/widgetRegistry.ts +2 -1
- package/src/backend/date-range/dateRanges.ts +16 -18
- package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
- package/src/backend/detail/InlineEditors.tsx +1 -1
- package/src/backend/injection/__tests__/useAppEvent.test.ts +16 -0
- package/src/backend/injection/useAppEvent.ts +2 -9
- package/src/backend/injection/widgetRegistry.ts +2 -1
- package/src/backend/messages/__tests__/MessageComposer.test.tsx +77 -0
- package/src/backend/messages/useMessageCompose.ts +64 -5
- package/src/backend/messages/useMessageComposeOperations.ts +70 -4
- package/src/backend/schedule/ScheduleCalendar.tsx +11 -1
- package/src/backend/schedule/ScheduleToolbar.tsx +11 -1
- package/src/primitives/calendar.tsx +2 -2
- package/src/primitives/date-format.ts +2 -2
- package/src/primitives/date-picker-helpers.ts +7 -9
- package/src/primitives/date-picker.tsx +2 -2
- package/src/primitives/date-range-picker.tsx +2 -2
|
@@ -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
|
|
@@ -185,6 +185,19 @@ function createErrorResponse(status: number, payload: Record<string, unknown>):
|
|
|
185
185
|
describe('<AiChat>', () => {
|
|
186
186
|
beforeEach(() => {
|
|
187
187
|
jest.clearAllMocks()
|
|
188
|
+
const apiCallMock = apiCall as unknown as jest.Mock
|
|
189
|
+
apiCallMock.mockReset()
|
|
190
|
+
apiCallMock.mockResolvedValue({
|
|
191
|
+
ok: true,
|
|
192
|
+
status: 200,
|
|
193
|
+
result: {
|
|
194
|
+
agentId: 'customers.account_assistant',
|
|
195
|
+
allowRuntimeModelOverride: false,
|
|
196
|
+
defaultProviderId: 'openai',
|
|
197
|
+
defaultModelId: 'gpt-5-mini',
|
|
198
|
+
providers: [],
|
|
199
|
+
},
|
|
200
|
+
})
|
|
188
201
|
window.localStorage.clear()
|
|
189
202
|
lastResizeObserver = null
|
|
190
203
|
})
|
|
@@ -238,7 +251,9 @@ describe('<AiChat>', () => {
|
|
|
238
251
|
'Default: openai / gpt-5-mini',
|
|
239
252
|
)
|
|
240
253
|
})
|
|
241
|
-
|
|
254
|
+
await waitFor(() => {
|
|
255
|
+
expect(window.localStorage.getItem('om-ai-model-picker:customers.account_assistant')).toBeNull()
|
|
256
|
+
})
|
|
242
257
|
})
|
|
243
258
|
|
|
244
259
|
it('does not send provider or model overrides while the model picker is on Default', async () => {
|
|
@@ -293,7 +308,7 @@ describe('<AiChat>', () => {
|
|
|
293
308
|
expect(parsedUrl.searchParams.get('agent')).toBe('customers.deal_analyzer')
|
|
294
309
|
expect(parsedUrl.searchParams.has('provider')).toBe(false)
|
|
295
310
|
expect(parsedUrl.searchParams.has('model')).toBe(false)
|
|
296
|
-
})
|
|
311
|
+
}, 15_000)
|
|
297
312
|
|
|
298
313
|
it('renders a compact footer before a constrained host is measured', async () => {
|
|
299
314
|
const apiCallMock = apiCall as unknown as jest.Mock
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { listAiServerConversations } from '../conversation-store'
|
|
2
|
+
|
|
3
|
+
describe('AI conversation store', () => {
|
|
4
|
+
const originalFetch = global.fetch
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
global.fetch = originalFetch
|
|
8
|
+
jest.restoreAllMocks()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('suppresses auth redirects for optional conversation sync calls', async () => {
|
|
12
|
+
const fetchMock = jest.fn(async () => new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 }))
|
|
13
|
+
global.fetch = fetchMock as unknown as typeof fetch
|
|
14
|
+
|
|
15
|
+
await expect(listAiServerConversations({ limit: 100 })).resolves.toBeNull()
|
|
16
|
+
|
|
17
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
18
|
+
'/api/ai_assistant/ai/conversations?limit=100',
|
|
19
|
+
expect.objectContaining({
|
|
20
|
+
credentials: 'include',
|
|
21
|
+
headers: expect.objectContaining({
|
|
22
|
+
Accept: 'application/json',
|
|
23
|
+
'x-om-forbidden-redirect': '0',
|
|
24
|
+
'x-om-unauthorized-redirect': '0',
|
|
25
|
+
}),
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { AiChatMessage, AiChatMessageUiPart } from './useAiChat'
|
|
4
|
+
|
|
5
|
+
const CONVERSATIONS_ENDPOINT = '/api/ai_assistant/ai/conversations'
|
|
6
|
+
|
|
7
|
+
export interface AiServerConversation {
|
|
8
|
+
conversationId: string
|
|
9
|
+
agentId: string
|
|
10
|
+
title: string | null
|
|
11
|
+
status: 'open' | 'closed'
|
|
12
|
+
visibility: 'private' | 'shared' | 'organization'
|
|
13
|
+
pageContext: Record<string, unknown> | null
|
|
14
|
+
createdAt: string
|
|
15
|
+
updatedAt: string
|
|
16
|
+
lastMessageAt: string | null
|
|
17
|
+
importedFromLocalAt: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AiServerMessage {
|
|
21
|
+
id: string
|
|
22
|
+
clientMessageId: string | null
|
|
23
|
+
role: 'user' | 'assistant' | 'system'
|
|
24
|
+
content: string
|
|
25
|
+
uiParts: unknown[]
|
|
26
|
+
attachmentIds: string[]
|
|
27
|
+
files: Array<Record<string, unknown>>
|
|
28
|
+
model: string | null
|
|
29
|
+
metadata: Record<string, unknown> | null
|
|
30
|
+
createdAt: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AiServerConversationListResponse {
|
|
34
|
+
items: AiServerConversation[]
|
|
35
|
+
nextCursor: string | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AiServerTranscriptResponse {
|
|
39
|
+
conversation: AiServerConversation
|
|
40
|
+
messages: AiServerMessage[]
|
|
41
|
+
nextCursor: string | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AiServerImportResponse {
|
|
45
|
+
conversation: AiServerConversation
|
|
46
|
+
importedMessageCount: number
|
|
47
|
+
skippedMessageCount: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readString(value: unknown): string | null {
|
|
51
|
+
return typeof value === 'string' && value.length > 0 ? value : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildAttachmentImagePreviewUrl(attachmentId: string): string {
|
|
55
|
+
const params = new URLSearchParams({
|
|
56
|
+
width: '320',
|
|
57
|
+
height: '320',
|
|
58
|
+
cropType: 'contain',
|
|
59
|
+
})
|
|
60
|
+
return `/api/attachments/image/${encodeURIComponent(attachmentId)}?${params.toString()}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getFetch(): typeof fetch | null {
|
|
64
|
+
const fetchImpl = (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch
|
|
65
|
+
return typeof fetchImpl === 'function' ? fetchImpl.bind(globalThis) : null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function requestJson<T>(
|
|
69
|
+
input: string,
|
|
70
|
+
init?: RequestInit,
|
|
71
|
+
): Promise<{ ok: true; status: number; data: T } | { ok: false; status: number; data: null }> {
|
|
72
|
+
const fetchImpl = getFetch()
|
|
73
|
+
if (!fetchImpl) return { ok: false, status: 0, data: null }
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetchImpl(input, {
|
|
76
|
+
credentials: 'include',
|
|
77
|
+
...init,
|
|
78
|
+
headers: {
|
|
79
|
+
Accept: 'application/json',
|
|
80
|
+
'x-om-forbidden-redirect': '0',
|
|
81
|
+
'x-om-unauthorized-redirect': '0',
|
|
82
|
+
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
|
83
|
+
...(init?.headers ?? {}),
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
if (!response.ok) return { ok: false, status: response.status, data: null }
|
|
87
|
+
return { ok: true, status: response.status, data: (await response.json()) as T }
|
|
88
|
+
} catch {
|
|
89
|
+
return { ok: false, status: 0, data: null }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function listAiServerConversations(options: {
|
|
94
|
+
agentId?: string | null
|
|
95
|
+
status?: 'open' | 'closed' | null
|
|
96
|
+
limit?: number
|
|
97
|
+
} = {}): Promise<AiServerConversation[] | null> {
|
|
98
|
+
const params = new URLSearchParams()
|
|
99
|
+
if (options.agentId) params.set('agent', options.agentId)
|
|
100
|
+
if (options.status) params.set('status', options.status)
|
|
101
|
+
params.set('limit', String(options.limit ?? 100))
|
|
102
|
+
const result = await requestJson<AiServerConversationListResponse>(
|
|
103
|
+
`${CONVERSATIONS_ENDPOINT}?${params.toString()}`,
|
|
104
|
+
)
|
|
105
|
+
return result.ok ? result.data.items : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function createAiServerConversation(input: {
|
|
109
|
+
agentId: string
|
|
110
|
+
conversationId: string
|
|
111
|
+
title?: string | null
|
|
112
|
+
pageContext?: Record<string, unknown> | null
|
|
113
|
+
}): Promise<AiServerConversation | null> {
|
|
114
|
+
const result = await requestJson<AiServerConversation>(CONVERSATIONS_ENDPOINT, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
agentId: input.agentId,
|
|
118
|
+
conversationId: input.conversationId,
|
|
119
|
+
title: input.title ?? undefined,
|
|
120
|
+
pageContext: input.pageContext ?? undefined,
|
|
121
|
+
}),
|
|
122
|
+
})
|
|
123
|
+
return result.ok ? result.data : null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function updateAiServerConversation(
|
|
127
|
+
conversationId: string,
|
|
128
|
+
patch: {
|
|
129
|
+
title?: string | null
|
|
130
|
+
status?: 'open' | 'closed'
|
|
131
|
+
pageContext?: Record<string, unknown> | null
|
|
132
|
+
},
|
|
133
|
+
): Promise<AiServerConversation | null> {
|
|
134
|
+
const result = await requestJson<AiServerConversation>(
|
|
135
|
+
`${CONVERSATIONS_ENDPOINT}/${encodeURIComponent(conversationId)}`,
|
|
136
|
+
{
|
|
137
|
+
method: 'PATCH',
|
|
138
|
+
body: JSON.stringify(patch),
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
return result.ok ? result.data : null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function loadAiServerTranscript(
|
|
145
|
+
conversationId: string,
|
|
146
|
+
options: { limit?: number } = {},
|
|
147
|
+
): Promise<AiServerTranscriptResponse | null> {
|
|
148
|
+
const params = new URLSearchParams()
|
|
149
|
+
params.set('limit', String(options.limit ?? 100))
|
|
150
|
+
const result = await requestJson<AiServerTranscriptResponse>(
|
|
151
|
+
`${CONVERSATIONS_ENDPOINT}/${encodeURIComponent(conversationId)}?${params.toString()}`,
|
|
152
|
+
)
|
|
153
|
+
return result.ok ? result.data : null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function importAiLocalConversation(input: {
|
|
157
|
+
agentId: string
|
|
158
|
+
conversationId: string
|
|
159
|
+
title?: string | null
|
|
160
|
+
status?: 'open' | 'closed'
|
|
161
|
+
pageContext?: Record<string, unknown> | null
|
|
162
|
+
messages: AiChatMessage[]
|
|
163
|
+
}): Promise<AiServerImportResponse | null> {
|
|
164
|
+
const result = await requestJson<AiServerImportResponse>(`${CONVERSATIONS_ENDPOINT}/import`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
body: JSON.stringify({
|
|
167
|
+
conversation: {
|
|
168
|
+
conversationId: input.conversationId,
|
|
169
|
+
agentId: input.agentId,
|
|
170
|
+
title: input.title ?? undefined,
|
|
171
|
+
status: input.status ?? 'open',
|
|
172
|
+
pageContext: input.pageContext ?? undefined,
|
|
173
|
+
},
|
|
174
|
+
messages: input.messages.slice(-100).map((message) => ({
|
|
175
|
+
clientMessageId: message.id,
|
|
176
|
+
role: message.role,
|
|
177
|
+
content: message.content,
|
|
178
|
+
uiParts: message.uiParts ?? [],
|
|
179
|
+
files: (message.files ?? []).map((file) => ({
|
|
180
|
+
id: file.id,
|
|
181
|
+
name: file.name,
|
|
182
|
+
mimeType: file.type,
|
|
183
|
+
})),
|
|
184
|
+
})),
|
|
185
|
+
}),
|
|
186
|
+
})
|
|
187
|
+
return result.ok ? result.data : null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function serverMessageToChatMessage(message: AiServerMessage): AiChatMessage | null {
|
|
191
|
+
if (message.role !== 'user' && message.role !== 'assistant') return null
|
|
192
|
+
const uiParts: AiChatMessageUiPart[] = []
|
|
193
|
+
message.uiParts.forEach((part, index) => {
|
|
194
|
+
if (!part || typeof part !== 'object') return
|
|
195
|
+
const value = part as Record<string, unknown>
|
|
196
|
+
const componentId =
|
|
197
|
+
typeof value.componentId === 'string'
|
|
198
|
+
? value.componentId
|
|
199
|
+
: typeof value.type === 'string'
|
|
200
|
+
? value.type
|
|
201
|
+
: null
|
|
202
|
+
if (!componentId) return
|
|
203
|
+
uiParts.push({
|
|
204
|
+
componentId,
|
|
205
|
+
payload: value.payload ?? value.props,
|
|
206
|
+
pendingActionId:
|
|
207
|
+
typeof value.pendingActionId === 'string' ? value.pendingActionId : undefined,
|
|
208
|
+
key:
|
|
209
|
+
typeof value.key === 'string'
|
|
210
|
+
? value.key
|
|
211
|
+
: `${message.id}:${index}:${componentId}`,
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
return {
|
|
215
|
+
id: message.clientMessageId ?? message.id,
|
|
216
|
+
role: message.role,
|
|
217
|
+
content: message.content,
|
|
218
|
+
files: message.files
|
|
219
|
+
.map((file, index) => {
|
|
220
|
+
const name = readString(file.name)
|
|
221
|
+
if (!name) return null
|
|
222
|
+
const type =
|
|
223
|
+
typeof file.mimeType === 'string'
|
|
224
|
+
? file.mimeType
|
|
225
|
+
: typeof file.type === 'string'
|
|
226
|
+
? file.type
|
|
227
|
+
: 'application/octet-stream'
|
|
228
|
+
const id = readString(file.id) ?? readString(message.attachmentIds[index])
|
|
229
|
+
const rawPreviewUrl =
|
|
230
|
+
readString(file.previewUrl) ??
|
|
231
|
+
readString(file.thumbnailUrl) ??
|
|
232
|
+
readString(file.url)
|
|
233
|
+
const previewUrl = type.startsWith('image/')
|
|
234
|
+
? rawPreviewUrl ?? (id ? buildAttachmentImagePreviewUrl(id) : undefined)
|
|
235
|
+
: undefined
|
|
236
|
+
return {
|
|
237
|
+
...(id ? { id } : {}),
|
|
238
|
+
name,
|
|
239
|
+
type,
|
|
240
|
+
...(previewUrl ? { previewUrl } : {}),
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
.filter((file): file is NonNullable<AiChatMessage['files']>[number] => file !== null),
|
|
244
|
+
uiParts,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -180,6 +180,7 @@ export function MutationPreviewCard(props: MutationPreviewCardProps) {
|
|
|
180
180
|
|
|
181
181
|
const batch = Array.isArray(action?.records) && action!.records!.length > 0 ? action!.records! : null
|
|
182
182
|
const summary = batch ? summarizeBatch(batch) : null
|
|
183
|
+
const hasReviewDetails = Boolean(batch)
|
|
183
184
|
|
|
184
185
|
return (
|
|
185
186
|
<section
|
|
@@ -242,7 +243,7 @@ export function MutationPreviewCard(props: MutationPreviewCardProps) {
|
|
|
242
243
|
)}
|
|
243
244
|
</div>
|
|
244
245
|
|
|
245
|
-
{expanded ? (
|
|
246
|
+
{expanded && hasReviewDetails ? (
|
|
246
247
|
<div className="mt-3 rounded-md border border-border bg-muted/20 p-3" data-ai-mutation-preview-details>
|
|
247
248
|
<FieldDiffCard
|
|
248
249
|
fieldDiff={action?.fieldDiff ?? null}
|
|
@@ -252,25 +253,29 @@ export function MutationPreviewCard(props: MutationPreviewCardProps) {
|
|
|
252
253
|
) : null}
|
|
253
254
|
|
|
254
255
|
<div className="mt-3 flex items-center justify-between gap-2">
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
256
|
+
{hasReviewDetails ? (
|
|
257
|
+
<Button
|
|
258
|
+
type="button"
|
|
259
|
+
variant="ghost"
|
|
260
|
+
size="sm"
|
|
261
|
+
onClick={() => setExpanded((value) => !value)}
|
|
262
|
+
data-ai-mutation-preview-review
|
|
263
|
+
>
|
|
264
|
+
<Eye className="size-4" aria-hidden />
|
|
265
|
+
<span>
|
|
266
|
+
{t(
|
|
267
|
+
'ai_assistant.chat.mutation_cards.preview.reviewDetails',
|
|
268
|
+
'Review details',
|
|
269
|
+
)}
|
|
270
|
+
</span>
|
|
271
|
+
<ChevronDown
|
|
272
|
+
className={`size-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
|
273
|
+
aria-hidden
|
|
274
|
+
/>
|
|
275
|
+
</Button>
|
|
276
|
+
) : (
|
|
277
|
+
<span aria-hidden />
|
|
278
|
+
)}
|
|
274
279
|
<div className="flex items-center gap-2">
|
|
275
280
|
<Button
|
|
276
281
|
type="button"
|