@open-mercato/ai-assistant 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/AGENTS.md +2 -2
- package/dist/modules/ai_assistant/acl.js +1 -0
- package/dist/modules/ai_assistant/acl.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +197 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js +272 -0
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/conversations/import/route.js +108 -0
- package/dist/modules/ai_assistant/api/ai/conversations/import/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/conversations/route.js +207 -0
- package/dist/modules/ai_assistant/api/ai/conversations/route.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities/AiChatConversation.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiChatConversation.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities/AiChatConversationParticipant.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiChatConversationParticipant.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities/AiChatMessage.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiChatMessage.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities.js +200 -0
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js +448 -0
- package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js.map +7 -0
- package/dist/modules/ai_assistant/data/validators.js +72 -0
- package/dist/modules/ai_assistant/data/validators.js.map +7 -0
- package/dist/modules/ai_assistant/i18n/de.json +3 -0
- package/dist/modules/ai_assistant/i18n/en.json +3 -0
- package/dist/modules/ai_assistant/i18n/es.json +3 -0
- package/dist/modules/ai_assistant/i18n/pl.json +3 -0
- package/dist/modules/ai_assistant/lib/conversation-storage.js +43 -0
- package/dist/modules/ai_assistant/lib/conversation-storage.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260518092853_ai_assistant.js +28 -0
- package/dist/modules/ai_assistant/migrations/Migration20260518092853_ai_assistant.js.map +7 -0
- package/dist/modules/ai_assistant/setup.js +1 -0
- package/dist/modules/ai_assistant/setup.js.map +2 -2
- package/generated/entities/ai_chat_conversation/index.ts +15 -0
- package/generated/entities/ai_chat_conversation_participant/index.ts +9 -0
- package/generated/entities/ai_chat_message/index.ts +16 -0
- package/generated/entities.ids.generated.ts +4 -1
- package/generated/entity-fields-registry.ts +46 -0
- package/jest.config.cjs +3 -1
- package/package.json +14 -15
- package/src/modules/ai_assistant/acl.ts +1 -0
- package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +107 -0
- package/src/modules/ai_assistant/api/ai/chat/route.ts +245 -1
- package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/route.ts +320 -0
- package/src/modules/ai_assistant/api/ai/conversations/__tests__/route.test.ts +93 -0
- package/src/modules/ai_assistant/api/ai/conversations/import/route.ts +122 -0
- package/src/modules/ai_assistant/api/ai/conversations/route.ts +241 -0
- package/src/modules/ai_assistant/data/entities/AiChatConversation.ts +2 -0
- package/src/modules/ai_assistant/data/entities/AiChatConversationParticipant.ts +2 -0
- package/src/modules/ai_assistant/data/entities/AiChatMessage.ts +2 -0
- package/src/modules/ai_assistant/data/entities.ts +255 -0
- package/src/modules/ai_assistant/data/repositories/AiChatConversationRepository.ts +597 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiChatConversationRepository.test.ts +592 -0
- package/src/modules/ai_assistant/data/validators.ts +134 -0
- package/src/modules/ai_assistant/i18n/de.json +3 -0
- package/src/modules/ai_assistant/i18n/en.json +3 -0
- package/src/modules/ai_assistant/i18n/es.json +3 -0
- package/src/modules/ai_assistant/i18n/pl.json +3 -0
- package/src/modules/ai_assistant/lib/conversation-storage.ts +93 -0
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +822 -0
- package/src/modules/ai_assistant/migrations/Migration20260518092853_ai_assistant.ts +39 -0
- package/src/modules/ai_assistant/setup.ts +1 -0
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from '../../../lib/model-allowlist'
|
|
29
29
|
import { AiTenantModelAllowlistRepository } from '../../../data/repositories/AiTenantModelAllowlistRepository'
|
|
30
30
|
import { AiAgentRuntimeOverrideRepository } from '../../../data/repositories/AiAgentRuntimeOverrideRepository'
|
|
31
|
+
import { createConversationStorage } from '../../../lib/conversation-storage'
|
|
31
32
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
32
33
|
|
|
33
34
|
const MAX_MESSAGES = 100
|
|
@@ -35,8 +36,23 @@ const MAX_MESSAGES = 100
|
|
|
35
36
|
const agentIdPattern = /^[a-z0-9_]+\.[a-z0-9_]+$/
|
|
36
37
|
|
|
37
38
|
const chatMessageSchema = z.object({
|
|
39
|
+
id: z.string().min(1).max(128).optional(),
|
|
38
40
|
role: z.enum(['user', 'assistant', 'system']),
|
|
39
41
|
content: z.string(),
|
|
42
|
+
uiParts: z.array(z.unknown()).optional(),
|
|
43
|
+
files: z
|
|
44
|
+
.array(
|
|
45
|
+
z
|
|
46
|
+
.object({
|
|
47
|
+
id: z.string().optional(),
|
|
48
|
+
name: z.string().optional(),
|
|
49
|
+
type: z.string().optional(),
|
|
50
|
+
mimeType: z.string().optional(),
|
|
51
|
+
size: z.number().optional(),
|
|
52
|
+
})
|
|
53
|
+
.passthrough(),
|
|
54
|
+
)
|
|
55
|
+
.optional(),
|
|
40
56
|
})
|
|
41
57
|
|
|
42
58
|
const pageContextSchema = z
|
|
@@ -189,6 +205,204 @@ function statusForDenyCode(code: AgentPolicyDenyCode): number {
|
|
|
189
205
|
}
|
|
190
206
|
}
|
|
191
207
|
|
|
208
|
+
function extractDataPayload(eventBlock: string): string | null {
|
|
209
|
+
const dataLines = eventBlock
|
|
210
|
+
.split('\n')
|
|
211
|
+
.filter((line) => line.startsWith('data:'))
|
|
212
|
+
.map((line) => (line.startsWith('data: ') ? line.slice(6) : line.slice(5)))
|
|
213
|
+
if (dataLines.length === 0) return null
|
|
214
|
+
return dataLines.join('\n')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function extractUiPartsFromToolOutput(output: unknown): unknown[] {
|
|
218
|
+
let parsed = output
|
|
219
|
+
if (typeof output === 'string') {
|
|
220
|
+
const trimmed = output.trim()
|
|
221
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return []
|
|
222
|
+
try {
|
|
223
|
+
parsed = JSON.parse(trimmed) as unknown
|
|
224
|
+
} catch {
|
|
225
|
+
return []
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!parsed || typeof parsed !== 'object') return []
|
|
229
|
+
const value = parsed as Record<string, unknown>
|
|
230
|
+
const parts: unknown[] = []
|
|
231
|
+
if (value.status === 'pending-confirmation' || value.status === 'awaiting-confirmation') {
|
|
232
|
+
const pendingActionId =
|
|
233
|
+
typeof value.pendingActionId === 'string' && value.pendingActionId.length > 0
|
|
234
|
+
? value.pendingActionId
|
|
235
|
+
: null
|
|
236
|
+
if (pendingActionId) {
|
|
237
|
+
parts.push({
|
|
238
|
+
componentId: 'mutation-preview-card',
|
|
239
|
+
pendingActionId,
|
|
240
|
+
payload: {
|
|
241
|
+
pendingActionId,
|
|
242
|
+
expiresAt: typeof value.expiresAt === 'string' ? value.expiresAt : undefined,
|
|
243
|
+
agentId:
|
|
244
|
+
typeof value.agentId === 'string'
|
|
245
|
+
? value.agentId
|
|
246
|
+
: typeof value.agent === 'string'
|
|
247
|
+
? value.agent
|
|
248
|
+
: undefined,
|
|
249
|
+
toolName: typeof value.toolName === 'string' ? value.toolName : undefined,
|
|
250
|
+
},
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (value.uiPart && typeof value.uiPart === 'object') parts.push(value.uiPart)
|
|
255
|
+
if (Array.isArray(value.uiParts)) parts.push(...value.uiParts)
|
|
256
|
+
return parts
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function extractAssistantSnapshot(
|
|
260
|
+
raw: string,
|
|
261
|
+
contentType: string | null,
|
|
262
|
+
): { content: string; uiParts: unknown[] } {
|
|
263
|
+
if (!contentType?.includes('event-stream')) {
|
|
264
|
+
return { content: raw, uiParts: [] }
|
|
265
|
+
}
|
|
266
|
+
let content = ''
|
|
267
|
+
const uiParts: unknown[] = []
|
|
268
|
+
for (const block of raw.split('\n\n')) {
|
|
269
|
+
const data = extractDataPayload(block)
|
|
270
|
+
if (!data || data === '[DONE]') continue
|
|
271
|
+
try {
|
|
272
|
+
const parsed = JSON.parse(data) as Record<string, unknown>
|
|
273
|
+
if (parsed.type === 'text-delta' && typeof parsed.delta === 'string') {
|
|
274
|
+
content += parsed.delta
|
|
275
|
+
} else if (parsed.type === 'text' && typeof parsed.content === 'string') {
|
|
276
|
+
content += parsed.content
|
|
277
|
+
} else if (parsed.type === 'tool-output-available') {
|
|
278
|
+
uiParts.push(...extractUiPartsFromToolOutput(parsed.output))
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
// Ignore SSE comments and malformed provider chunks.
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { content, uiParts }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function persistChatTurnStart(input: {
|
|
288
|
+
container: Awaited<ReturnType<typeof createRequestContainer>>
|
|
289
|
+
tenantId: string | null | undefined
|
|
290
|
+
organizationId: string | null | undefined
|
|
291
|
+
userId: string
|
|
292
|
+
agentId: string
|
|
293
|
+
conversationId: string | null
|
|
294
|
+
pageContext?: Record<string, unknown>
|
|
295
|
+
messages: AiChatRequest['messages']
|
|
296
|
+
attachmentIds?: string[]
|
|
297
|
+
}): Promise<{ conversationId: string; userClientMessageId: string | null } | null> {
|
|
298
|
+
if (!input.tenantId || !input.conversationId) return null
|
|
299
|
+
const repo = createConversationStorage(input.container)
|
|
300
|
+
const ctx = {
|
|
301
|
+
tenantId: input.tenantId,
|
|
302
|
+
organizationId: input.organizationId ?? null,
|
|
303
|
+
userId: input.userId,
|
|
304
|
+
}
|
|
305
|
+
await repo.createOrGet(
|
|
306
|
+
{
|
|
307
|
+
conversationId: input.conversationId,
|
|
308
|
+
agentId: input.agentId,
|
|
309
|
+
pageContext: input.pageContext ?? null,
|
|
310
|
+
},
|
|
311
|
+
ctx,
|
|
312
|
+
)
|
|
313
|
+
const userMessage = [...input.messages].reverse().find((message) => message.role === 'user')
|
|
314
|
+
if (!userMessage) return { conversationId: input.conversationId, userClientMessageId: null }
|
|
315
|
+
await repo.appendMessage(
|
|
316
|
+
input.conversationId,
|
|
317
|
+
{
|
|
318
|
+
clientMessageId: userMessage.id,
|
|
319
|
+
role: 'user',
|
|
320
|
+
content: userMessage.content,
|
|
321
|
+
uiParts: userMessage.uiParts,
|
|
322
|
+
attachmentIds: input.attachmentIds,
|
|
323
|
+
files: userMessage.files?.map((file, index) => {
|
|
324
|
+
const id = file.id ?? input.attachmentIds?.[index]
|
|
325
|
+
const mimeType = file.mimeType ?? file.type
|
|
326
|
+
return {
|
|
327
|
+
...(id ? { id } : {}),
|
|
328
|
+
...(file.name ? { name: file.name } : {}),
|
|
329
|
+
...(mimeType ? { mimeType } : {}),
|
|
330
|
+
...(typeof file.size === 'number' ? { size: file.size } : {}),
|
|
331
|
+
}
|
|
332
|
+
}),
|
|
333
|
+
},
|
|
334
|
+
ctx,
|
|
335
|
+
)
|
|
336
|
+
return {
|
|
337
|
+
conversationId: input.conversationId,
|
|
338
|
+
userClientMessageId: userMessage.id ?? null,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function persistAssistantOnStreamCompletion(input: {
|
|
343
|
+
response: Response
|
|
344
|
+
container: Awaited<ReturnType<typeof createRequestContainer>>
|
|
345
|
+
tenantId: string | null | undefined
|
|
346
|
+
organizationId: string | null | undefined
|
|
347
|
+
userId: string
|
|
348
|
+
conversationId: string
|
|
349
|
+
userClientMessageId: string | null
|
|
350
|
+
}): Response {
|
|
351
|
+
if (!input.response.body || !input.tenantId) return input.response
|
|
352
|
+
const tenantId = input.tenantId
|
|
353
|
+
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()
|
|
354
|
+
const writer = writable.getWriter()
|
|
355
|
+
const decoder = new TextDecoder()
|
|
356
|
+
const contentType = input.response.headers.get('content-type')
|
|
357
|
+
|
|
358
|
+
async function pump(): Promise<void> {
|
|
359
|
+
const reader = input.response.body!.getReader()
|
|
360
|
+
let raw = ''
|
|
361
|
+
try {
|
|
362
|
+
for (;;) {
|
|
363
|
+
const { value, done } = await reader.read()
|
|
364
|
+
if (done) break
|
|
365
|
+
if (!value) continue
|
|
366
|
+
raw += decoder.decode(value, { stream: true })
|
|
367
|
+
await writer.write(value)
|
|
368
|
+
}
|
|
369
|
+
raw += decoder.decode()
|
|
370
|
+
const assistant = extractAssistantSnapshot(raw, contentType)
|
|
371
|
+
if (assistant.content.trim() || assistant.uiParts.length > 0) {
|
|
372
|
+
const repo = createConversationStorage(input.container)
|
|
373
|
+
await repo.appendMessage(
|
|
374
|
+
input.conversationId,
|
|
375
|
+
{
|
|
376
|
+
clientMessageId: input.userClientMessageId
|
|
377
|
+
? `${input.userClientMessageId}:assistant`
|
|
378
|
+
: undefined,
|
|
379
|
+
role: 'assistant',
|
|
380
|
+
content: assistant.content,
|
|
381
|
+
uiParts: assistant.uiParts,
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
tenantId,
|
|
385
|
+
organizationId: input.organizationId ?? null,
|
|
386
|
+
userId: input.userId,
|
|
387
|
+
},
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
} catch (error) {
|
|
391
|
+
console.error('[AI Chat Agent] Conversation persistence failure:', error)
|
|
392
|
+
} finally {
|
|
393
|
+
reader.releaseLock()
|
|
394
|
+
await writer.close().catch(() => undefined)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
void pump()
|
|
399
|
+
return new Response(readable, {
|
|
400
|
+
status: input.response.status,
|
|
401
|
+
statusText: input.response.statusText,
|
|
402
|
+
headers: input.response.headers,
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
|
|
192
406
|
export async function POST(req: NextRequest): Promise<Response> {
|
|
193
407
|
const auth = await getAuthFromRequest(req)
|
|
194
408
|
if (!auth) {
|
|
@@ -419,7 +633,27 @@ export async function POST(req: NextRequest): Promise<Response> {
|
|
|
419
633
|
? resolveLoopBudgetPreset(rawLoopBudget)
|
|
420
634
|
: undefined
|
|
421
635
|
|
|
422
|
-
|
|
636
|
+
const effectiveConversationId = bodyResult.data.sessionId ?? bodyResult.data.conversationId ?? null
|
|
637
|
+
let persistedTurn:
|
|
638
|
+
| { conversationId: string; userClientMessageId: string | null }
|
|
639
|
+
| null = null
|
|
640
|
+
try {
|
|
641
|
+
persistedTurn = await persistChatTurnStart({
|
|
642
|
+
container,
|
|
643
|
+
tenantId: auth.tenantId ?? null,
|
|
644
|
+
organizationId: auth.orgId ?? null,
|
|
645
|
+
userId: auth.sub,
|
|
646
|
+
agentId,
|
|
647
|
+
conversationId: effectiveConversationId,
|
|
648
|
+
pageContext: bodyResult.data.pageContext,
|
|
649
|
+
messages: bodyResult.data.messages,
|
|
650
|
+
attachmentIds: bodyResult.data.attachmentIds,
|
|
651
|
+
})
|
|
652
|
+
} catch (error) {
|
|
653
|
+
console.error('[AI Chat Agent] Failed to persist user message:', error)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const response = await runAiAgentText({
|
|
423
657
|
agentId,
|
|
424
658
|
messages: bodyResult.data.messages as unknown as UIMessage[],
|
|
425
659
|
attachmentIds: bodyResult.data.attachmentIds,
|
|
@@ -439,6 +673,16 @@ export async function POST(req: NextRequest): Promise<Response> {
|
|
|
439
673
|
loop: loopFromPreset,
|
|
440
674
|
emitLoopTrace: true,
|
|
441
675
|
})
|
|
676
|
+
if (!persistedTurn) return response
|
|
677
|
+
return persistAssistantOnStreamCompletion({
|
|
678
|
+
response,
|
|
679
|
+
container,
|
|
680
|
+
tenantId: auth.tenantId ?? null,
|
|
681
|
+
organizationId: auth.orgId ?? null,
|
|
682
|
+
userId: auth.sub,
|
|
683
|
+
conversationId: persistedTurn.conversationId,
|
|
684
|
+
userClientMessageId: persistedTurn.userClientMessageId,
|
|
685
|
+
})
|
|
442
686
|
} catch (error) {
|
|
443
687
|
if (error instanceof AgentPolicyError) {
|
|
444
688
|
return jsonError(statusForDenyCode(error.code), error.message, error.code)
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
7
|
+
import {
|
|
8
|
+
aiChatConversationTranscriptQuerySchema,
|
|
9
|
+
aiChatConversationUpdateSchema,
|
|
10
|
+
} from '../../../../data/validators'
|
|
11
|
+
import { hasRequiredFeatures } from '../../../../lib/auth'
|
|
12
|
+
import {
|
|
13
|
+
createConversationStorage,
|
|
14
|
+
serializeAiChatConversation,
|
|
15
|
+
serializeAiChatMessage,
|
|
16
|
+
} from '../../../../lib/conversation-storage'
|
|
17
|
+
|
|
18
|
+
const REQUIRED_FEATURE = 'ai_assistant.view'
|
|
19
|
+
const MANAGE_CONVERSATIONS_FEATURE = 'ai_assistant.conversations.manage'
|
|
20
|
+
|
|
21
|
+
const conversationIdParamSchema = z.object({
|
|
22
|
+
conversationId: z
|
|
23
|
+
.string()
|
|
24
|
+
.trim()
|
|
25
|
+
.min(1, 'conversationId must be a non-empty string')
|
|
26
|
+
.max(128, 'conversationId exceeds the maximum length of 128 characters'),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export const openApi: OpenApiRouteDoc = {
|
|
30
|
+
tag: 'AI Assistant',
|
|
31
|
+
summary: 'Per-conversation AI chat operations',
|
|
32
|
+
methods: {
|
|
33
|
+
GET: {
|
|
34
|
+
operationId: 'aiAssistantGetConversation',
|
|
35
|
+
summary: 'Fetch a conversation summary and recent transcript.',
|
|
36
|
+
description:
|
|
37
|
+
'Returns `{ conversation, messages, nextCursor }` for the supplied `conversationId`. ' +
|
|
38
|
+
'View-only callers can load only their own conversations. Callers with ' +
|
|
39
|
+
'`ai_assistant.conversations.manage` can load conversations across users in the same ' +
|
|
40
|
+
'tenant/organization. Messages are ordered ascending by `createdAt`. The `before` cursor ' +
|
|
41
|
+
'returns the next older page when paging back through long transcripts.',
|
|
42
|
+
responses: [
|
|
43
|
+
{
|
|
44
|
+
status: 200,
|
|
45
|
+
description: 'Conversation transcript page for the authenticated owner.',
|
|
46
|
+
mediaType: 'application/json',
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
errors: [
|
|
50
|
+
{ status: 400, description: 'Invalid path or query parameters.' },
|
|
51
|
+
{ status: 401, description: 'Unauthenticated caller.' },
|
|
52
|
+
{ status: 403, description: 'Caller lacks the `ai_assistant.view` feature.' },
|
|
53
|
+
{ status: 404, description: 'No conversation accessible to the caller.' },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
PATCH: {
|
|
57
|
+
operationId: 'aiAssistantUpdateConversation',
|
|
58
|
+
summary: 'Update an existing conversation.',
|
|
59
|
+
description:
|
|
60
|
+
'Accepts a partial body containing any of `title`, `status`, `pageContext`. Setting ' +
|
|
61
|
+
'`status` to `closed` archives the conversation while keeping its transcript intact. ' +
|
|
62
|
+
'View-only callers can update only their own conversations; conversation managers can ' +
|
|
63
|
+
'update conversations in the same tenant/organization.',
|
|
64
|
+
responses: [
|
|
65
|
+
{
|
|
66
|
+
status: 200,
|
|
67
|
+
description: 'Updated conversation summary.',
|
|
68
|
+
mediaType: 'application/json',
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
errors: [
|
|
72
|
+
{ status: 400, description: 'Invalid request body.' },
|
|
73
|
+
{ status: 401, description: 'Unauthenticated caller.' },
|
|
74
|
+
{ status: 403, description: 'Caller lacks the `ai_assistant.view` feature.' },
|
|
75
|
+
{ status: 404, description: 'No conversation accessible to the caller.' },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
DELETE: {
|
|
79
|
+
operationId: 'aiAssistantDeleteConversation',
|
|
80
|
+
summary: 'Soft-delete a conversation and its messages.',
|
|
81
|
+
description:
|
|
82
|
+
'View-only callers can delete only their own conversations. Callers with ' +
|
|
83
|
+
'`ai_assistant.conversations.manage` can delete conversations in the same tenant/organization. ' +
|
|
84
|
+
'Marks the conversation row and every undeleted message row with a `deleted_at` timestamp ' +
|
|
85
|
+
'in one transaction. The transcript remains in the database for audit/restore until a future ' +
|
|
86
|
+
'retention worker hard-deletes it.',
|
|
87
|
+
responses: [
|
|
88
|
+
{
|
|
89
|
+
status: 200,
|
|
90
|
+
description: 'Soft-delete acknowledgment.',
|
|
91
|
+
mediaType: 'application/json',
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
errors: [
|
|
95
|
+
{ status: 401, description: 'Unauthenticated caller.' },
|
|
96
|
+
{ status: 403, description: 'Caller lacks the `ai_assistant.view` feature.' },
|
|
97
|
+
{ status: 404, description: 'No conversation accessible to the caller.' },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const metadata = {
|
|
104
|
+
GET: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
|
|
105
|
+
PATCH: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
|
|
106
|
+
DELETE: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface RouteContext {
|
|
110
|
+
params: Promise<{ conversationId: string }>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function jsonError(
|
|
114
|
+
status: number,
|
|
115
|
+
message: string,
|
|
116
|
+
code: string,
|
|
117
|
+
extra?: Record<string, unknown>,
|
|
118
|
+
): NextResponse {
|
|
119
|
+
return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function resolveCallerContext(req: NextRequest, context: RouteContext): Promise<
|
|
123
|
+
| { kind: 'unauthorized' }
|
|
124
|
+
| { kind: 'forbidden' }
|
|
125
|
+
| { kind: 'missing-tenant' }
|
|
126
|
+
| { kind: 'invalid-id'; issues: unknown }
|
|
127
|
+
| {
|
|
128
|
+
kind: 'ok'
|
|
129
|
+
tenantId: string
|
|
130
|
+
organizationId: string | null
|
|
131
|
+
userId: string
|
|
132
|
+
conversationId: string
|
|
133
|
+
canManageConversations: boolean
|
|
134
|
+
}
|
|
135
|
+
> {
|
|
136
|
+
const auth = await getAuthFromRequest(req)
|
|
137
|
+
if (!auth) return { kind: 'unauthorized' }
|
|
138
|
+
const rawParams = await context.params
|
|
139
|
+
const parseResult = conversationIdParamSchema.safeParse(rawParams)
|
|
140
|
+
if (!parseResult.success) {
|
|
141
|
+
return { kind: 'invalid-id', issues: parseResult.error.issues }
|
|
142
|
+
}
|
|
143
|
+
const container = await createRequestContainer()
|
|
144
|
+
const rbacService = container.resolve<RbacService>('rbacService')
|
|
145
|
+
const acl = await rbacService.loadAcl(auth.sub, {
|
|
146
|
+
tenantId: auth.tenantId,
|
|
147
|
+
organizationId: auth.orgId,
|
|
148
|
+
})
|
|
149
|
+
if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {
|
|
150
|
+
return { kind: 'forbidden' }
|
|
151
|
+
}
|
|
152
|
+
const canManageConversations = hasRequiredFeatures(
|
|
153
|
+
[MANAGE_CONVERSATIONS_FEATURE],
|
|
154
|
+
acl.features,
|
|
155
|
+
acl.isSuperAdmin,
|
|
156
|
+
rbacService,
|
|
157
|
+
)
|
|
158
|
+
if (!auth.tenantId) return { kind: 'missing-tenant' }
|
|
159
|
+
return {
|
|
160
|
+
kind: 'ok',
|
|
161
|
+
tenantId: auth.tenantId,
|
|
162
|
+
organizationId: auth.orgId ?? null,
|
|
163
|
+
userId: auth.sub,
|
|
164
|
+
conversationId: parseResult.data.conversationId,
|
|
165
|
+
canManageConversations,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function GET(req: NextRequest, context: RouteContext): Promise<Response> {
|
|
170
|
+
const callerCtx = await resolveCallerContext(req, context)
|
|
171
|
+
if (callerCtx.kind === 'unauthorized') return jsonError(401, 'Unauthorized', 'unauthenticated')
|
|
172
|
+
if (callerCtx.kind === 'invalid-id') {
|
|
173
|
+
return jsonError(400, 'Invalid conversation id.', 'validation_error', {
|
|
174
|
+
issues: callerCtx.issues,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
if (callerCtx.kind === 'forbidden') {
|
|
178
|
+
return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
|
|
179
|
+
}
|
|
180
|
+
if (callerCtx.kind === 'missing-tenant') {
|
|
181
|
+
return jsonError(404, 'Conversation not found.', 'conversation_not_found')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const url = new URL(req.url)
|
|
185
|
+
const queryResult = aiChatConversationTranscriptQuerySchema.safeParse({
|
|
186
|
+
limit: url.searchParams.get('limit') ?? undefined,
|
|
187
|
+
before: url.searchParams.get('before') ?? undefined,
|
|
188
|
+
})
|
|
189
|
+
if (!queryResult.success) {
|
|
190
|
+
return jsonError(400, 'Invalid query parameters.', 'validation_error', {
|
|
191
|
+
issues: queryResult.error.issues,
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const container = await createRequestContainer()
|
|
197
|
+
const repo = createConversationStorage(container)
|
|
198
|
+
const transcript = await repo.getTranscript(
|
|
199
|
+
callerCtx.conversationId,
|
|
200
|
+
{
|
|
201
|
+
tenantId: callerCtx.tenantId,
|
|
202
|
+
organizationId: callerCtx.organizationId,
|
|
203
|
+
userId: callerCtx.userId,
|
|
204
|
+
canManageConversations: callerCtx.canManageConversations,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
limit: queryResult.data.limit,
|
|
208
|
+
before: queryResult.data.before ?? null,
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
if (!transcript) {
|
|
212
|
+
return jsonError(404, 'Conversation not found.', 'conversation_not_found')
|
|
213
|
+
}
|
|
214
|
+
return NextResponse.json({
|
|
215
|
+
conversation: serializeAiChatConversation(transcript.conversation),
|
|
216
|
+
messages: transcript.messages.map(serializeAiChatMessage),
|
|
217
|
+
nextCursor: transcript.nextCursor,
|
|
218
|
+
})
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.error('[AI Conversation GET] Failure:', error)
|
|
221
|
+
return jsonError(
|
|
222
|
+
500,
|
|
223
|
+
error instanceof Error ? error.message : 'Failed to load conversation.',
|
|
224
|
+
'internal_error',
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function PATCH(req: NextRequest, context: RouteContext): Promise<Response> {
|
|
230
|
+
const callerCtx = await resolveCallerContext(req, context)
|
|
231
|
+
if (callerCtx.kind === 'unauthorized') return jsonError(401, 'Unauthorized', 'unauthenticated')
|
|
232
|
+
if (callerCtx.kind === 'invalid-id') {
|
|
233
|
+
return jsonError(400, 'Invalid conversation id.', 'validation_error', {
|
|
234
|
+
issues: callerCtx.issues,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
if (callerCtx.kind === 'forbidden') {
|
|
238
|
+
return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
|
|
239
|
+
}
|
|
240
|
+
if (callerCtx.kind === 'missing-tenant') {
|
|
241
|
+
return jsonError(404, 'Conversation not found.', 'conversation_not_found')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let rawBody: unknown
|
|
245
|
+
try {
|
|
246
|
+
rawBody = await req.json()
|
|
247
|
+
} catch {
|
|
248
|
+
return jsonError(400, 'Request body must be valid JSON.', 'validation_error')
|
|
249
|
+
}
|
|
250
|
+
const parseResult = aiChatConversationUpdateSchema.safeParse(rawBody)
|
|
251
|
+
if (!parseResult.success) {
|
|
252
|
+
return jsonError(400, 'Invalid conversation patch.', 'validation_error', {
|
|
253
|
+
issues: parseResult.error.issues,
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const container = await createRequestContainer()
|
|
259
|
+
const repo = createConversationStorage(container)
|
|
260
|
+
const row = await repo.update(
|
|
261
|
+
callerCtx.conversationId,
|
|
262
|
+
parseResult.data,
|
|
263
|
+
{
|
|
264
|
+
tenantId: callerCtx.tenantId,
|
|
265
|
+
organizationId: callerCtx.organizationId,
|
|
266
|
+
userId: callerCtx.userId,
|
|
267
|
+
canManageConversations: callerCtx.canManageConversations,
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
return NextResponse.json(serializeAiChatConversation(row))
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error instanceof Error && error.name === 'AiChatConversationAccessError') {
|
|
273
|
+
return jsonError(404, 'Conversation not found.', 'conversation_not_found')
|
|
274
|
+
}
|
|
275
|
+
console.error('[AI Conversation PATCH] Failure:', error)
|
|
276
|
+
return jsonError(
|
|
277
|
+
500,
|
|
278
|
+
error instanceof Error ? error.message : 'Failed to update conversation.',
|
|
279
|
+
'internal_error',
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function DELETE(req: NextRequest, context: RouteContext): Promise<Response> {
|
|
285
|
+
const callerCtx = await resolveCallerContext(req, context)
|
|
286
|
+
if (callerCtx.kind === 'unauthorized') return jsonError(401, 'Unauthorized', 'unauthenticated')
|
|
287
|
+
if (callerCtx.kind === 'invalid-id') {
|
|
288
|
+
return jsonError(400, 'Invalid conversation id.', 'validation_error', {
|
|
289
|
+
issues: callerCtx.issues,
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
if (callerCtx.kind === 'forbidden') {
|
|
293
|
+
return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
|
|
294
|
+
}
|
|
295
|
+
if (callerCtx.kind === 'missing-tenant') {
|
|
296
|
+
return jsonError(404, 'Conversation not found.', 'conversation_not_found')
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const container = await createRequestContainer()
|
|
301
|
+
const repo = createConversationStorage(container)
|
|
302
|
+
await repo.softDelete(callerCtx.conversationId, {
|
|
303
|
+
tenantId: callerCtx.tenantId,
|
|
304
|
+
organizationId: callerCtx.organizationId,
|
|
305
|
+
userId: callerCtx.userId,
|
|
306
|
+
canManageConversations: callerCtx.canManageConversations,
|
|
307
|
+
})
|
|
308
|
+
return NextResponse.json({ ok: true })
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (error instanceof Error && error.name === 'AiChatConversationAccessError') {
|
|
311
|
+
return jsonError(404, 'Conversation not found.', 'conversation_not_found')
|
|
312
|
+
}
|
|
313
|
+
console.error('[AI Conversation DELETE] Failure:', error)
|
|
314
|
+
return jsonError(
|
|
315
|
+
500,
|
|
316
|
+
error instanceof Error ? error.message : 'Failed to delete conversation.',
|
|
317
|
+
'internal_error',
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const authMock = jest.fn()
|
|
2
|
+
const loadAclMock = jest.fn()
|
|
3
|
+
const hasAllFeaturesMock = jest.fn()
|
|
4
|
+
const createRequestContainerMock = jest.fn()
|
|
5
|
+
const listConversationsMock = jest.fn()
|
|
6
|
+
|
|
7
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
8
|
+
getAuthFromRequest: (...args: unknown[]) => authMock(...args),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
12
|
+
createRequestContainer: (...args: unknown[]) => createRequestContainerMock(...args),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
jest.mock('../../../../lib/conversation-storage', () => ({
|
|
16
|
+
createConversationStorage: jest.fn(() => ({
|
|
17
|
+
list: (...args: unknown[]) => listConversationsMock(...args),
|
|
18
|
+
})),
|
|
19
|
+
serializeAiChatConversation: (row: unknown) => row,
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
import { GET } from '../route'
|
|
23
|
+
|
|
24
|
+
function buildRequest(query = ''): Request {
|
|
25
|
+
return new Request(`http://localhost/api/ai_assistant/ai/conversations${query}`, {
|
|
26
|
+
method: 'GET',
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('GET /api/ai/conversations', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
jest.clearAllMocks()
|
|
33
|
+
authMock.mockResolvedValue({
|
|
34
|
+
sub: 'user-1',
|
|
35
|
+
tenantId: 'tenant-1',
|
|
36
|
+
orgId: 'org-1',
|
|
37
|
+
})
|
|
38
|
+
hasAllFeaturesMock.mockImplementation((required: string[], features: string[]) =>
|
|
39
|
+
required.every((feature) => features.includes(feature)),
|
|
40
|
+
)
|
|
41
|
+
createRequestContainerMock.mockResolvedValue({
|
|
42
|
+
resolve: (name: string) => {
|
|
43
|
+
if (name === 'rbacService') {
|
|
44
|
+
return {
|
|
45
|
+
loadAcl: (...args: unknown[]) => loadAclMock(...args),
|
|
46
|
+
hasAllFeatures: (...args: unknown[]) => hasAllFeaturesMock(...args),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
loadAclMock.mockResolvedValue({
|
|
53
|
+
features: ['ai_assistant.view'],
|
|
54
|
+
isSuperAdmin: false,
|
|
55
|
+
})
|
|
56
|
+
listConversationsMock.mockResolvedValue({ items: [], nextCursor: null })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('passes owner-only scope to storage for view-only callers', async () => {
|
|
60
|
+
const response = await GET(buildRequest('?agent=catalog.assistant') as any)
|
|
61
|
+
expect(response.status).toBe(200)
|
|
62
|
+
expect(listConversationsMock).toHaveBeenCalledWith(
|
|
63
|
+
{
|
|
64
|
+
tenantId: 'tenant-1',
|
|
65
|
+
organizationId: 'org-1',
|
|
66
|
+
userId: 'user-1',
|
|
67
|
+
canManageConversations: false,
|
|
68
|
+
},
|
|
69
|
+
expect.objectContaining({
|
|
70
|
+
agentId: 'catalog.assistant',
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('passes tenant-scoped manage scope when the caller has conversation management', async () => {
|
|
76
|
+
loadAclMock.mockResolvedValueOnce({
|
|
77
|
+
features: ['ai_assistant.view', 'ai_assistant.conversations.manage'],
|
|
78
|
+
isSuperAdmin: false,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const response = await GET(buildRequest() as any)
|
|
82
|
+
expect(response.status).toBe(200)
|
|
83
|
+
expect(listConversationsMock).toHaveBeenCalledWith(
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
tenantId: 'tenant-1',
|
|
86
|
+
organizationId: 'org-1',
|
|
87
|
+
userId: 'user-1',
|
|
88
|
+
canManageConversations: true,
|
|
89
|
+
}),
|
|
90
|
+
expect.any(Object),
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
})
|