diff --git a/listify-api/src/assistant/assistant.module.ts b/listify-api/src/assistant/assistant.module.ts index 7bd9356..f7a4c03 100644 --- a/listify-api/src/assistant/assistant.module.ts +++ b/listify-api/src/assistant/assistant.module.ts @@ -1,12 +1,17 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthModule } from '../auth/auth.module'; +import { ListsModule } from '../lists/lists.module'; import { AssistantChatLogEntity } from './assistant-chat-log.entity'; import { AssistantController } from './assistant.controller'; import { AssistantService } from './assistant.service'; @Module({ - imports: [AuthModule, TypeOrmModule.forFeature([AssistantChatLogEntity])], + imports: [ + AuthModule, + ListsModule, + TypeOrmModule.forFeature([AssistantChatLogEntity]), + ], controllers: [AssistantController], providers: [AssistantService], }) diff --git a/listify-api/src/assistant/assistant.service.spec.ts b/listify-api/src/assistant/assistant.service.spec.ts index dbc9d5e..4c8aea9 100644 --- a/listify-api/src/assistant/assistant.service.spec.ts +++ b/listify-api/src/assistant/assistant.service.spec.ts @@ -9,6 +9,9 @@ describe('AssistantService', () => { create: jest.Mock; save: jest.Mock; }; + let listRealtimeService: { + publishSnapshot: jest.Mock; + }; let service: AssistantService; beforeEach(() => { @@ -19,7 +22,13 @@ describe('AssistantService', () => { create: jest.fn((input) => input), save: jest.fn(async (input) => input), }; - service = new AssistantService(chatLogsRepository as never); + listRealtimeService = { + publishSnapshot: jest.fn(), + }; + service = new AssistantService( + chatLogsRepository as never, + listRealtimeService as never, + ); }); afterEach(() => { @@ -86,6 +95,7 @@ describe('AssistantService', () => { }, actions: [], }); + expect(listRealtimeService.publishSnapshot).not.toHaveBeenCalled(); expect(chatLogsRepository.save).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1', @@ -129,6 +139,27 @@ describe('AssistantService', () => { }); it('extracts the final assistant message from multi-completion responses', async () => { + const updatedList = { + id: 'list-1', + ownerId: 'user-1', + accessRole: 'owner', + name: 'Einkauf', + kind: 'shopping', + items: [ + { + id: 'item-1', + title: 'Milch', + required: true, + checked: false, + position: 0, + createdAt: '2026-06-12T00:00:00.000Z', + updatedAt: '2026-06-12T00:00:00.000Z', + }, + ], + collaborators: [], + createdAt: '2026-06-12T00:00:00.000Z', + updatedAt: '2026-06-12T00:00:00.000Z', + }; const providerResponse = { id: 'chatcmpl-test', object: 'chat.multi_completion', @@ -144,8 +175,8 @@ describe('AssistantService', () => { id: 'call-1', type: 'function', function: { - name: 'listify_list_existing_lists', - arguments: '{"includeItems": false}', + name: 'listify_add_list_item', + arguments: '{"listId": "list-1", "title": "Milch"}', }, }, ], @@ -154,6 +185,14 @@ describe('AssistantService', () => { role: 'tool', index: 1, content: [{ type: 'text', text: '{"lists":[]}' }], + tool_call_id: 'call-1', + metadata: { + mcp_meta: { + structuredContent: { + list: updatedList, + }, + }, + }, }, { role: 'assistant', @@ -172,6 +211,18 @@ describe('AssistantService', () => { }); expect(result.message.content).toBe('Hier sind deine bestehenden Listen.'); + expect(result.actions).toEqual([ + { + type: 'list.item_added', + listId: 'list-1', + itemTitle: 'Milch', + list: updatedList, + }, + ]); + expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith( + 'user-1', + updatedList, + ); expect(chatLogsRepository.save).toHaveBeenCalledWith( expect.objectContaining({ responsePayload: providerResponse, diff --git a/listify-api/src/assistant/assistant.service.ts b/listify-api/src/assistant/assistant.service.ts index 415b015..5e14d3a 100644 --- a/listify-api/src/assistant/assistant.service.ts +++ b/listify-api/src/assistant/assistant.service.ts @@ -6,8 +6,11 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; import { Repository } from 'typeorm'; +import { UserList } from '../list-templates/list-template.types'; +import { ListRealtimeService } from '../lists/list-realtime.service'; import { AssistantChatLogEntity } from './assistant-chat-log.entity'; import { + AssistantAction, AssistantChatMessage, AssistantChatRequest, AssistantChatResponse, @@ -21,6 +24,18 @@ interface MistralAgentCompletionResponse { messages?: Array<{ role?: string; content?: string | null | unknown[]; + tool_call_id?: string; + tool_calls?: Array<{ + id?: string; + function?: { + name?: string; + }; + }>; + metadata?: { + mcp_meta?: { + structuredContent?: unknown; + }; + }; }>; }>; } @@ -32,6 +47,7 @@ export class AssistantService { constructor( @InjectRepository(AssistantChatLogEntity) private readonly chatLogsRepository: Repository, + private readonly listRealtimeService: ListRealtimeService, ) {} async chat( @@ -41,6 +57,11 @@ export class AssistantService { const messages = this.normalizeMessages(request.messages); const response = await this.callMistralAgent(userId, messages); const content = this.extractAssistantContent(response); + const actions = this.extractActions(response); + + actions.forEach((action) => { + this.listRealtimeService.publishSnapshot(userId, action.list); + }); if (!content) { throw new ServiceUnavailableException('Mistral response was empty.'); @@ -51,7 +72,7 @@ export class AssistantService { role: 'assistant', content, }, - actions: [], + actions, }; } @@ -177,6 +198,116 @@ export class AssistantService { return null; } + private extractActions(responsePayload: unknown): AssistantAction[] { + if (!responsePayload || typeof responsePayload !== 'object') { + return []; + } + + const actions: AssistantAction[] = []; + const response = responsePayload as MistralAgentCompletionResponse; + + for (const choice of response.choices ?? []) { + const toolNamesById = new Map(); + + for (const message of choice.messages ?? []) { + for (const toolCall of message.tool_calls ?? []) { + if (toolCall.id && toolCall.function?.name) { + toolNamesById.set(toolCall.id, toolCall.function.name); + } + } + } + + for (const message of choice.messages ?? []) { + if (message.role !== 'tool') { + continue; + } + + const structuredContent = message.metadata?.mcp_meta?.structuredContent; + const list = this.listFromStructuredContent(structuredContent); + + if (!list) { + continue; + } + + const toolName = message.tool_call_id + ? toolNamesById.get(message.tool_call_id) + : undefined; + + if (toolName?.includes('create_list')) { + actions.push({ + type: 'list.created', + listId: list.id, + list, + }); + continue; + } + + if (toolName?.includes('add_list_item')) { + actions.push({ + type: 'list.item_added', + listId: list.id, + itemTitle: this.lastItemTitle(list), + list, + }); + continue; + } + + actions.push({ + type: 'list.item_added', + listId: list.id, + itemTitle: this.lastItemTitle(list), + list, + }); + } + } + + return this.uniqueActions(actions); + } + + private listFromStructuredContent(value: unknown): UserList | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + const structuredContent = value as { list?: unknown }; + const list = structuredContent.list; + + if (!list || typeof list !== 'object' || Array.isArray(list)) { + return null; + } + + const candidate = list as Partial; + + if ( + typeof candidate.id !== 'string' || + typeof candidate.name !== 'string' || + !Array.isArray(candidate.items) + ) { + return null; + } + + return candidate as UserList; + } + + private lastItemTitle(list: UserList): string { + return list.items.at(-1)?.title ?? 'Eintrag'; + } + + private uniqueActions(actions: AssistantAction[]): AssistantAction[] { + const seen = new Set(); + + return actions.filter((action) => { + const key = `${action.type}:${action.listId}:${action.type === 'list.item_added' ? action.itemTitle : ''}`; + + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); + } + private async recordChatLog(input: { userId: string; agentId: string;