diff --git a/listify-api/README.md b/listify-api/README.md index 3e160ff..5feac36 100644 --- a/listify-api/README.md +++ b/listify-api/README.md @@ -46,14 +46,14 @@ $ npm run start:prod ## Mistral assistant -The in-app assistant calls a configured Mistral agent from the API server. Configure the key and agent id in the API environment, never in the Angular client: +The in-app assistant calls the Mistral Conversations API from the API server. Configure the key and optional model in the API environment, never in the Angular client: ```bash MISTRAL_API_KEY=your-mistral-api-key -MISTRAL_AGENT_ID=your-mistral-agent-id +MISTRAL_MODEL=mistral-large-latest ``` -The Mistral agent should have the `listify` connector attached. The authenticated frontend calls `POST /api/assistant/chat`; the API forwards the conversation to `POST /v1/agents/completions` and returns the assistant text. Listify does not parse or execute tool calls locally in this path. +The authenticated frontend calls `POST /api/assistant/chat`; the API forwards the conversation to `POST /v1/conversations` with the `listify` connector enabled and returns the assistant text. Listify inspects returned tool outputs and refreshes local list state when a list was created or changed. Every Mistral response is stored in `assistant_chat_logs`. The table includes the sanitized provider request, the full raw provider response, the extracted assistant text sent back to the UI, response status and timing metadata. @@ -116,8 +116,8 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den } ``` - - Output enthaelt `suggestions` mit `name`, `description`, `kind`, `items`, optionalem Template-Bezug und `rationale`. - - Schreibt keine Daten und legt keine Liste an. +- Output enthaelt `suggestions` mit `name`, `description`, `kind`, `items`, optionalem Template-Bezug und `rationale`. +- Schreibt keine Daten und legt keine Liste an. - `create_list` - Erstellt eine neue Liste fuer den angemeldeten User. @@ -135,8 +135,8 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den } ``` - - `items` ist optional. Wenn Items angegeben sind, werden sie nach dem Erstellen der Liste in Reihenfolge hinzugefuegt. - - Output enthaelt `list` mit der erstellten Liste inklusive Items. +- `items` ist optional. Wenn Items angegeben sind, werden sie nach dem Erstellen der Liste in Reihenfolge hinzugefuegt. +- Output enthaelt `list` mit der erstellten Liste inklusive Items. - `add_list_item` - Fuegt ein Item zu einer bestehenden Liste hinzu, auf die der angemeldete User Zugriff hat. @@ -151,7 +151,7 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den } ``` - - Output enthaelt `list` mit der aktualisierten Liste. +- Output enthaelt `list` mit der aktualisierten Liste. - `create_template` - Erstellt ein neues Template fuer den angemeldeten User. @@ -169,7 +169,7 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den } ``` - - Output enthaelt `template` mit dem erstellten Template inklusive Items. +- Output enthaelt `template` mit dem erstellten Template inklusive Items. - `add_template_item` - Fuegt ein Item zu einem bestehenden Template hinzu, das dem angemeldeten User gehoert. @@ -183,7 +183,7 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den } ``` - - Output enthaelt `template` mit dem aktualisierten Template. +- Output enthaelt `template` mit dem aktualisierten Template. ### Minimaler MCP-Request diff --git a/listify-api/src/assistant/assistant.service.spec.ts b/listify-api/src/assistant/assistant.service.spec.ts index aad48e7..b841b4f 100644 --- a/listify-api/src/assistant/assistant.service.spec.ts +++ b/listify-api/src/assistant/assistant.service.spec.ts @@ -9,7 +9,7 @@ describe('AssistantService', () => { ].join(' '); const originalFetch = global.fetch; const originalApiKey = process.env.MISTRAL_API_KEY; - const originalAgentId = process.env.MISTRAL_AGENT_ID; + const originalModel = process.env.MISTRAL_MODEL; let chatLogsRepository: { create: jest.Mock; find: jest.Mock; @@ -26,7 +26,7 @@ describe('AssistantService', () => { beforeEach(() => { process.env.MISTRAL_API_KEY = 'test-key'; - process.env.MISTRAL_AGENT_ID = 'agent-listify'; + process.env.MISTRAL_MODEL = 'mistral-large-test'; global.fetch = jest.fn(); chatLogsRepository = { create: jest.fn((input) => input), @@ -51,10 +51,10 @@ describe('AssistantService', () => { afterEach(() => { global.fetch = originalFetch; process.env.MISTRAL_API_KEY = originalApiKey; - process.env.MISTRAL_AGENT_ID = originalAgentId; + process.env.MISTRAL_MODEL = originalModel; }); - it('forwards messages to the configured Mistral agent', async () => { + it('starts a Mistral conversation with the configured model', async () => { const providerResponse = { choices: [ { @@ -78,7 +78,7 @@ describe('AssistantService', () => { }); expect(global.fetch).toHaveBeenCalledWith( - 'https://api.mistral.ai/v1/agents/completions', + 'https://api.mistral.ai/v1/conversations', expect.objectContaining({ method: 'POST', headers: { @@ -86,12 +86,12 @@ describe('AssistantService', () => { 'Content-Type': 'application/json', }, body: JSON.stringify({ - agent_id: 'agent-listify', - messages: [ + model: 'mistral-large-test', + inputs: [ { role: 'assistant', content: 'Hallo' }, { role: 'user', content: 'Hallo' }, - { role: 'system', content: connectorSystemMessage }, ], + instructions: connectorSystemMessage, tools: [ { type: 'connector', @@ -99,9 +99,6 @@ describe('AssistantService', () => { }, ], stream: false, - response_format: { - type: 'text', - }, }), }), ); @@ -117,11 +114,16 @@ describe('AssistantService', () => { expect.objectContaining({ userId: 'user-1', provider: 'mistral', - endpoint: 'https://api.mistral.ai/v1/agents/completions', - agentId: 'agent-listify', + endpoint: 'https://api.mistral.ai/v1/conversations', + agentId: null, statusCode: 200, requestPayload: expect.objectContaining({ - agent_id: 'agent-listify', + model: 'mistral-large-test', + inputs: [ + { role: 'assistant', content: 'Hallo' }, + { role: 'user', content: 'Hallo' }, + ], + instructions: connectorSystemMessage, tools: [{ type: 'connector', connector_id: 'listify' }], }), responsePayload: providerResponse, @@ -131,6 +133,64 @@ describe('AssistantService', () => { ); }); + it('extracts assistant content and created list actions from conversation outputs', async () => { + const createdList = { + id: 'list-1', + ownerId: 'user-1', + accessRole: 'owner', + name: 'Einkauf', + kind: 'shopping', + items: [], + collaborators: [], + createdAt: '2026-06-12T00:00:00.000Z', + updatedAt: '2026-06-12T00:00:00.000Z', + }; + const providerResponse = { + conversation_id: 'conv-1', + outputs: [ + { + type: 'tool.execution', + tool_name: 'listify_create_list', + result: { list: createdList }, + }, + { + type: 'message.output', + role: 'assistant', + content: 'Ich habe die Liste Einkauf angelegt.', + }, + ], + }; + mockMistralResponse(providerResponse); + + const result = await service.chat('user-1', { + messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }], + }); + + expect(result).toEqual({ + message: { + role: 'assistant', + content: 'Ich habe die Liste Einkauf angelegt.', + }, + actions: [ + { + type: 'list.created', + listId: 'list-1', + list: createdList, + }, + ], + }); + expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith( + 'user-1', + createdList, + ); + expect(chatLogsRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + responsePayload: providerResponse, + assistantContent: 'Ich habe die Liste Einkauf angelegt.', + }), + ); + }); + it('answers open list read requests locally without calling Mistral', async () => { listsService.listLists.mockResolvedValue([ { @@ -262,15 +322,9 @@ describe('AssistantService', () => { }); const payload = getMistralRequestPayload(); - const contextMessage = payload.messages.at(-2); - const contextContent = contextMessage?.content ?? ''; + const contextContent = payload.instructions; - expect(contextMessage).toEqual( - expect.objectContaining({ - role: 'system', - content: expect.stringContaining('Aktueller Listify-Kontext:'), - }), - ); + expect(contextContent).toContain('Aktueller Listify-Kontext:'); expect(contextContent).toContain( 'Der User befindet sich auf einer Listendetailseite.', ); @@ -287,10 +341,7 @@ describe('AssistantService', () => { expect(contextContent).not.toContain('ada@example.com'); expect(contextContent).not.toContain('grace@example.com'); expect(contextContent).not.toContain('checkedByUserId'); - expect(payload.messages.at(-1)).toEqual({ - role: 'system', - content: connectorSystemMessage, - }); + expect(contextContent).toContain(connectorSystemMessage); }); it('adds list items locally when the user writes onto the current list', async () => { @@ -390,7 +441,7 @@ describe('AssistantService', () => { statusCode: 502, responsePayload: providerResponse, assistantContent: null, - errorMessage: 'Mistral agent request failed.', + errorMessage: 'Mistral conversation request failed.', }), ); }); @@ -720,40 +771,20 @@ describe('AssistantService', () => { ); }); - it('fails clearly when the agent id is missing', async () => { - delete process.env.MISTRAL_AGENT_ID; - - await expect( - service.chat('user-1', { - messages: [{ role: 'user', content: 'Hallo' }], - }), - ).rejects.toThrow(ServiceUnavailableException); - expect(chatLogsRepository.save).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'user-1', - agentId: null, - statusCode: null, - responsePayload: null, - assistantContent: null, - errorMessage: 'Mistral agent id is not configured.', - }), - ); - }); - it('lists the latest chat logs for the current user', async () => { chatLogsRepository.find.mockResolvedValue([ { id: 'log-1', userId: 'user-1', provider: 'mistral', - endpoint: 'https://api.mistral.ai/v1/agents/completions', - agentId: 'agent-listify', + endpoint: 'https://api.mistral.ai/v1/conversations', + agentId: null, statusCode: 502, durationMs: 123, - requestPayload: { messages: [] }, + requestPayload: { inputs: [] }, responsePayload: { message: 'connector failed' }, assistantContent: null, - errorMessage: 'Mistral agent request failed.', + errorMessage: 'Mistral conversation request failed.', createdAt: new Date('2026-06-24T08:00:00.000Z'), }, ]); @@ -769,14 +800,14 @@ describe('AssistantService', () => { { id: 'log-1', provider: 'mistral', - endpoint: 'https://api.mistral.ai/v1/agents/completions', - agentId: 'agent-listify', + endpoint: 'https://api.mistral.ai/v1/conversations', + agentId: null, statusCode: 502, durationMs: 123, - requestPayload: { messages: [] }, + requestPayload: { inputs: [] }, responsePayload: { message: 'connector failed' }, assistantContent: null, - errorMessage: 'Mistral agent request failed.', + errorMessage: 'Mistral conversation request failed.', createdAt: '2026-06-24T08:00:00.000Z', }, ]); @@ -792,7 +823,8 @@ function mockMistralResponse(response: object, ok = true, status = 200): void { } function getMistralRequestPayload(): { - messages: Array<{ role: string; content: string }>; + inputs: Array<{ role: string; content: string }>; + instructions: string; } { const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? []; const body = init?.body; @@ -802,6 +834,7 @@ function getMistralRequestPayload(): { } return JSON.parse(body) as { - messages: Array<{ role: string; content: string }>; + inputs: Array<{ role: string; content: string }>; + instructions: string; }; } diff --git a/listify-api/src/assistant/assistant.service.ts b/listify-api/src/assistant/assistant.service.ts index 8f3023f..3fa92f0 100644 --- a/listify-api/src/assistant/assistant.service.ts +++ b/listify-api/src/assistant/assistant.service.ts @@ -65,7 +65,7 @@ type NormalizedAssistantPageContext = } | { page: 'unknown'; route: string }; -interface MistralAgentCompletionResponse { +interface MistralCompletionResponse { choices?: Array<{ message?: { content?: string | null; @@ -89,9 +89,28 @@ interface MistralAgentCompletionResponse { }>; } +interface MistralConversationResponse { + outputs?: Array<{ + type?: string; + role?: string; + content?: string | null | unknown[]; + result?: unknown; + output?: unknown; + tool_name?: string; + name?: string; + metadata?: { + mcp_meta?: { + structuredContent?: unknown; + }; + }; + }>; + output_text?: string | null; +} + @Injectable() export class AssistantService { - private readonly endpoint = 'https://api.mistral.ai/v1/agents/completions'; + private readonly endpoint = 'https://api.mistral.ai/v1/conversations'; + private readonly defaultModel = 'mistral-large-latest'; constructor( @InjectRepository(AssistantChatLogEntity) @@ -129,7 +148,11 @@ export class AssistantService { return localMutationResponse; } - const response = await this.callMistralAgent(userId, messages, context); + const response = await this.callMistralConversation( + userId, + messages, + context, + ); let actions = this.extractActions(response, { assumeCreatedListForUnknownToolResult: isCreationRequest, }); @@ -470,28 +493,28 @@ export class AssistantService { return lines.join('\n'); } - private async callMistralAgent( + private async callMistralConversation( userId: string, messages: AssistantChatMessage[], context: NormalizedAssistantPageContext | null, - ): Promise { + ): Promise { const apiKey = process.env.MISTRAL_API_KEY; - const agentId = process.env.MISTRAL_AGENT_ID; + const model = process.env.MISTRAL_MODEL ?? this.defaultModel; const contextMessage = this.createContextSystemMessage(context); + const instructions = [ + contextMessage?.content, + [ + 'Benutze fuer Listify-Daten immer den listify Connector.', + 'Wenn der User eine Liste anlegen will, musst du create_list verwenden und darfst keinen JSON-Entwurf als Antwort ausgeben.', + 'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.', + ].join(' '), + ] + .filter((content): content is string => Boolean(content)) + .join('\n\n'); const requestPayload = { - agent_id: agentId, - messages: [ - ...messages, - ...(contextMessage ? [contextMessage] : []), - { - role: 'system', - content: [ - 'Benutze fuer Listify-Daten immer den listify Connector.', - 'Wenn der User eine Liste anlegen will, musst du create_list verwenden und darfst keinen JSON-Entwurf als Antwort ausgeben.', - 'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.', - ].join(' '), - }, - ], + model, + inputs: messages, + instructions, tools: [ { type: 'connector', @@ -499,15 +522,12 @@ export class AssistantService { }, ], stream: false, - response_format: { - type: 'text', - }, }; if (!apiKey) { await this.recordChatLog({ userId, - agentId: agentId ?? null, + agentId: null, requestPayload, responsePayload: null, statusCode: null, @@ -520,22 +540,6 @@ export class AssistantService { ); } - if (!agentId) { - await this.recordChatLog({ - userId, - agentId: null, - requestPayload, - responsePayload: null, - statusCode: null, - durationMs: 0, - assistantContent: null, - errorMessage: 'Mistral agent id is not configured.', - }); - throw new ServiceUnavailableException( - 'Mistral agent id is not configured.', - ); - } - const startedAt = Date.now(); let statusCode: number | null = null; let responsePayload: unknown = null; @@ -555,22 +559,24 @@ export class AssistantService { if (!response.ok) { await this.recordChatLog({ userId, - agentId, + agentId: null, requestPayload, responsePayload, statusCode, durationMs: Date.now() - startedAt, assistantContent, - errorMessage: 'Mistral agent request failed.', + errorMessage: 'Mistral conversation request failed.', }); - throw new ServiceUnavailableException('Mistral agent request failed.'); + throw new ServiceUnavailableException( + 'Mistral conversation request failed.', + ); } assistantContent = this.extractAssistantContent(responsePayload); await this.recordChatLog({ userId, - agentId, + agentId: null, requestPayload, responsePayload, statusCode, @@ -579,7 +585,7 @@ export class AssistantService { errorMessage: null, }); - return responsePayload as MistralAgentCompletionResponse; + return responsePayload as MistralCompletionResponse; } private async readResponsePayload(response: Response): Promise { @@ -601,7 +607,7 @@ export class AssistantService { return null; } - const response = responsePayload as MistralAgentCompletionResponse; + const response = responsePayload as MistralCompletionResponse; const directContent = response.choices?.[0]?.message?.content?.trim(); if (directContent && !this.looksLikeJson(directContent)) { @@ -622,6 +628,25 @@ export class AssistantService { } } + const conversation = responsePayload as MistralConversationResponse; + const outputText = conversation.output_text?.trim(); + + if (outputText && !this.looksLikeJson(outputText)) { + return outputText; + } + + for (const output of [...(conversation.outputs ?? [])].reverse()) { + const content = this.contentToText(output.content).trim(); + + if ( + content && + !this.looksLikeJson(content) && + (output.role === 'assistant' || output.type?.includes('message')) + ) { + return content; + } + } + return null; } @@ -634,7 +659,7 @@ export class AssistantService { } const actions: AssistantAction[] = []; - const response = responsePayload as MistralAgentCompletionResponse; + const response = responsePayload as MistralCompletionResponse; for (const choice of response.choices ?? []) { const toolNamesById = new Map(); @@ -701,6 +726,43 @@ export class AssistantService { } } + const conversation = responsePayload as MistralConversationResponse; + + for (const output of conversation.outputs ?? []) { + const structuredContent = output.metadata?.mcp_meta?.structuredContent; + const list = + this.listFromStructuredContent(structuredContent) ?? + this.listFromStructuredContent(output.result) ?? + this.listFromStructuredContent(output.output) ?? + this.listFromToolContent(output.content) ?? + this.listFromNestedContent(output); + + if (!list) { + continue; + } + + const toolName = output.tool_name ?? output.name; + + if ( + toolName?.includes('create_list') || + options.assumeCreatedListForUnknownToolResult + ) { + actions.push({ + type: 'list.created', + listId: list.id, + list, + }); + continue; + } + + actions.push({ + type: 'list.item_added', + listId: list.id, + itemTitle: this.lastItemTitle(list), + list, + }); + } + return this.uniqueActions(actions); } @@ -746,6 +808,40 @@ export class AssistantService { return null; } + private listFromNestedContent(value: unknown): UserList | null { + if (!value || typeof value !== 'object') { + return null; + } + + const directList = this.listFromStructuredContent(value); + + if (directList) { + return directList; + } + + if (Array.isArray(value)) { + for (const item of value) { + const nestedList = this.listFromNestedContent(item); + + if (nestedList) { + return nestedList; + } + } + + return null; + } + + for (const nestedValue of Object.values(value as Record)) { + const nestedList = this.listFromNestedContent(nestedValue); + + if (nestedList) { + return nestedList; + } + } + + return null; + } + private contentToText(value: unknown): string { return this.contentTextParts(value).join('\n'); } diff --git a/listify-api/src/lists/lists.service.spec.ts b/listify-api/src/lists/lists.service.spec.ts index 7be5c9e..c234555 100644 --- a/listify-api/src/lists/lists.service.spec.ts +++ b/listify-api/src/lists/lists.service.spec.ts @@ -17,7 +17,7 @@ import { UserListShareEntity } from './user-list-share.entity'; describe('ListsService', () => { const originalFetch = global.fetch; const originalApiKey = process.env.MISTRAL_API_KEY; - const originalAgentId = process.env.MISTRAL_AGENT_ID; + const originalModel = process.env.MISTRAL_MODEL; let service: ListsService; let listsRepository: InMemoryRepository; let templatesRepository: InMemoryRepository; @@ -45,7 +45,7 @@ describe('ListsService', () => { afterEach(() => { global.fetch = originalFetch; restoreEnv('MISTRAL_API_KEY', originalApiKey); - restoreEnv('MISTRAL_AGENT_ID', originalAgentId); + restoreEnv('MISTRAL_MODEL', originalModel); }); it('creates and lists concrete lists for the owning user', async () => { @@ -116,8 +116,9 @@ describe('ListsService', () => { service.updateList('user-1', list.id, { name: 'Wieder da' }), ).rejects.toThrow(NotFoundException); expect( - (await listsRepository.find()).find((storedList) => storedList.id === list.id) - ?.deletedAt, + (await listsRepository.find()).find( + (storedList) => storedList.id === list.id, + )?.deletedAt, ).toBeInstanceOf(Date); }); @@ -313,12 +314,9 @@ describe('ListsService', () => { quantity: 2, required: false, }); - await service.updateItem( - 'user-1', - list.id, - withFirstItem.items[0].id, - { checked: true }, - ); + await service.updateItem('user-1', list.id, withFirstItem.items[0].id, { + checked: true, + }); const template = await service.createTemplateFromList( 'user-1', @@ -343,7 +341,9 @@ describe('ListsService', () => { position: 1, }), ]); - expect((template.items[0] as { checked?: unknown }).checked).toBeUndefined(); + expect( + (template.items[0] as { checked?: unknown }).checked, + ).toBeUndefined(); expect(await templatesRepository.find()).toHaveLength(1); }); @@ -416,7 +416,7 @@ describe('ListsService', () => { it('suggests normalized items and filters existing titles', async () => { process.env.MISTRAL_API_KEY = 'test-key'; - process.env.MISTRAL_AGENT_ID = 'agent-listify'; + process.env.MISTRAL_MODEL = 'mistral-large-test'; const list = await service.createList('user-1', { name: 'Sommerurlaub', description: 'Eine Woche am Meer', @@ -479,7 +479,7 @@ describe('ListsService', () => { ], }); expect(global.fetch).toHaveBeenCalledWith( - 'https://api.mistral.ai/v1/agents/completions', + 'https://api.mistral.ai/v1/conversations', expect.objectContaining({ method: 'POST', headers: { @@ -489,17 +489,21 @@ describe('ListsService', () => { }), ); const requestPayload = getMistralRequestPayload(); - expect(requestPayload.messages[1].content).toContain('Name: Sommerurlaub'); - expect(requestPayload.messages[1].content).toContain( + expect(requestPayload.model).toBe('mistral-large-test'); + expect(requestPayload.inputs[0].content).toContain('Name: Sommerurlaub'); + expect(requestPayload.inputs[0].content).toContain( 'Beschreibung: Eine Woche am Meer', ); - expect(requestPayload.messages[1].content).toContain('Titel: Pass'); + expect(requestPayload.inputs[0].content).toContain('Titel: Pass'); + expect(requestPayload.instructions).toContain( + 'Du erzeugst Listify-Item-Vorschlaege.', + ); expect(requestPayload.tools).toBeUndefined(); }); it('creates a list and returns item suggestions for it', async () => { process.env.MISTRAL_API_KEY = 'test-key'; - process.env.MISTRAL_AGENT_ID = 'agent-listify'; + process.env.MISTRAL_MODEL = 'mistral-large-test'; mockMistralResponse({ choices: [ { @@ -537,18 +541,18 @@ describe('ListsService', () => { await expect(service.listLists('user-1')).resolves.toHaveLength(1); const requestPayload = getMistralRequestPayload(); - expect(requestPayload.messages[1].content).toContain('Name: Sommerfest'); - expect(requestPayload.messages[1].content).toContain( + expect(requestPayload.inputs[0].content).toContain('Name: Sommerfest'); + expect(requestPayload.inputs[0].content).toContain( 'Beschreibung: Planung fuer Team-Event', ); - expect(requestPayload.messages[1].content).toContain( + expect(requestPayload.inputs[0].content).toContain( 'Vorhandene Items: keine', ); }); it('returns an empty suggestion list for malformed provider content', async () => { process.env.MISTRAL_API_KEY = 'test-key'; - process.env.MISTRAL_AGENT_ID = 'agent-listify'; + process.env.MISTRAL_MODEL = 'mistral-large-test'; const list = await service.createList('user-1', { name: 'Einkauf', kind: 'shopping', @@ -564,7 +568,6 @@ describe('ListsService', () => { it('fails clearly when item suggestions are not configured', async () => { delete process.env.MISTRAL_API_KEY; - process.env.MISTRAL_AGENT_ID = 'agent-listify'; const list = await service.createList('user-1', { name: 'Einkauf', kind: 'shopping', @@ -586,7 +589,9 @@ function mockMistralResponse(response: object, ok = true, status = 200): void { } function getMistralRequestPayload(): { - messages: Array<{ role: string; content: string }>; + model: string; + inputs: Array<{ role: string; content: string }>; + instructions: string; tools?: unknown; } { const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? []; @@ -597,7 +602,9 @@ function getMistralRequestPayload(): { } return JSON.parse(body) as { - messages: Array<{ role: string; content: string }>; + model: string; + inputs: Array<{ role: string; content: string }>; + instructions: string; tools?: unknown; }; } diff --git a/listify-api/src/lists/lists.service.ts b/listify-api/src/lists/lists.service.ts index 0ba7ddf..145e257 100644 --- a/listify-api/src/lists/lists.service.ts +++ b/listify-api/src/lists/lists.service.ts @@ -46,7 +46,7 @@ export interface CreateListWithItemSuggestionsResponse { suggestions: ListItemSuggestion[]; } -interface MistralAgentCompletionResponse { +interface MistralCompletionResponse { choices?: Array<{ message?: { content?: string | null; @@ -56,12 +56,19 @@ interface MistralAgentCompletionResponse { content?: string | null | unknown[]; }>; }>; + outputs?: Array<{ + type?: string; + role?: string; + content?: string | null | unknown[]; + }>; + output_text?: string | null; } @Injectable() export class ListsService { private readonly itemSuggestionsEndpoint = - 'https://api.mistral.ai/v1/agents/completions'; + 'https://api.mistral.ai/v1/conversations'; + private readonly defaultMistralModel = 'mistral-large-latest'; constructor( @InjectRepository(UserListEntity) @@ -84,7 +91,10 @@ export class ListsService { private readonly templateItemsRepository?: Repository, ) {} - async createList(ownerId: string, createDto: CreateListDto): Promise { + async createList( + ownerId: string, + createDto: CreateListDto, + ): Promise { const list = this.listsRepository.create({ id: randomUUID(), ownerId, @@ -122,7 +132,10 @@ export class ListsService { const list = await this.createList(ownerId, createDto); const listEntity = await this.findAccessibleList(ownerId, list.id); const response = await this.callMistralForItemSuggestions(listEntity); - const suggestions = this.normalizeItemSuggestions(response, listEntity.items); + const suggestions = this.normalizeItemSuggestions( + response, + listEntity.items, + ); return { list, @@ -220,7 +233,10 @@ export class ListsService { } async getList(ownerId: string, listId: string): Promise { - return this.toUserList(await this.findAccessibleList(ownerId, listId), ownerId); + return this.toUserList( + await this.findAccessibleList(ownerId, listId), + ownerId, + ); } async updateList( @@ -268,7 +284,10 @@ export class ListsService { return userList; } - async deleteList(ownerId: string, listId: string): Promise<{ message: string }> { + async deleteList( + ownerId: string, + listId: string, + ): Promise<{ message: string }> { const list = await this.findOwnedList(ownerId, listId); const accessorIds = this.listAccessorIds(list); const metadata = { @@ -303,7 +322,9 @@ export class ListsService { const targetUserId = this.requireShareUserId(shareDto.userId); if (targetUserId === ownerId) { - throw new BadRequestException('List owner cannot be added as collaborator.'); + throw new BadRequestException( + 'List owner cannot be added as collaborator.', + ); } const targetUser = await this.usersRepository.findOne({ @@ -612,7 +633,9 @@ export class ListsService { const list = await this.findAccessibleList(ownerId, listId); if (list.ownerId !== ownerId) { - throw new ForbiddenException('Only the list owner can perform this action.'); + throw new ForbiddenException( + 'Only the list owner can perform this action.', + ); } return list; @@ -730,7 +753,9 @@ export class ListsService { } if (typeof value !== 'string') { - throw new BadRequestException('Reminder time must be an ISO date string.'); + throw new BadRequestException( + 'Reminder time must be an ISO date string.', + ); } const normalizedValue = value.trim(); @@ -742,7 +767,9 @@ export class ListsService { const reminderAt = new Date(normalizedValue); if (Number.isNaN(reminderAt.getTime())) { - throw new BadRequestException('Reminder time must be an ISO date string.'); + throw new BadRequestException( + 'Reminder time must be an ISO date string.', + ); } return reminderAt; @@ -779,10 +806,13 @@ export class ListsService { ].filter((userId, index, userIds) => userIds.indexOf(userId) === index); } - private async hydrateListAccessRelations(list: UserListEntity): Promise { - list.owner ??= (await this.usersRepository.findOne({ - where: { id: list.ownerId }, - })) ?? undefined; + private async hydrateListAccessRelations( + list: UserListEntity, + ): Promise { + list.owner ??= + (await this.usersRepository.findOne({ + where: { id: list.ownerId }, + })) ?? undefined; const storedShares = await this.listSharesRepository.find({ where: { listId: list.id }, @@ -791,9 +821,10 @@ export class ListsService { list.shares = storedShares; for (const share of list.shares) { - share.user ??= (await this.usersRepository.findOne({ - where: { id: share.userId }, - })) ?? undefined; + share.user ??= + (await this.usersRepository.findOne({ + where: { id: share.userId }, + })) ?? undefined; } } @@ -833,7 +864,9 @@ export class ListsService { name: list.name, description: list.description ?? undefined, kind: list.kind, - reminderAt: list.reminderAt ? this.toIsoString(list.reminderAt) : undefined, + reminderAt: list.reminderAt + ? this.toIsoString(list.reminderAt) + : undefined, items: (list.items ?? []) .sort((left, right) => left.position - right.position) .map((item) => this.toUserListItem(item)), @@ -904,14 +937,12 @@ export class ListsService { list: UserListEntity, ): Promise { const apiKey = process.env.MISTRAL_API_KEY; - const agentId = process.env.MISTRAL_AGENT_ID; + const model = process.env.MISTRAL_MODEL ?? this.defaultMistralModel; if (!apiKey) { - throw new ServiceUnavailableException('Mistral API key is not configured.'); - } - - if (!agentId) { - throw new ServiceUnavailableException('Mistral agent id is not configured.'); + throw new ServiceUnavailableException( + 'Mistral API key is not configured.', + ); } const response = await fetch(this.itemSuggestionsEndpoint, { @@ -921,28 +952,24 @@ export class ListsService { 'Content-Type': 'application/json', }, body: JSON.stringify({ - agent_id: agentId, - messages: [ - { - role: 'system', - content: - 'Du erzeugst Listify-Item-Vorschlaege. Antworte nur mit JSON im Format {"suggestions":[{"title":"...","notes":"...","quantity":1,"required":true}]}. Keine Markdown-Ausgabe.', - }, + model, + inputs: [ { role: 'user', content: this.createItemSuggestionPrompt(list), }, ], + instructions: + 'Du erzeugst Listify-Item-Vorschlaege. Antworte nur mit JSON im Format {"suggestions":[{"title":"...","notes":"...","quantity":1,"required":true}]}. Keine Markdown-Ausgabe.', stream: false, - response_format: { - type: 'json_object', - }, }), }); const responsePayload = await this.readMistralResponsePayload(response); if (!response.ok) { - throw new ServiceUnavailableException('Mistral agent request failed.'); + throw new ServiceUnavailableException( + 'Mistral conversation request failed.', + ); } return responsePayload; @@ -983,7 +1010,9 @@ export class ListsService { return lines.join('\n'); } - private async readMistralResponsePayload(response: Response): Promise { + private async readMistralResponsePayload( + response: Response, + ): Promise { const rawBody = await response.text(); if (!rawBody) { @@ -1038,7 +1067,7 @@ export class ListsService { return null; } - const response = responsePayload as MistralAgentCompletionResponse; + const response = responsePayload as MistralCompletionResponse; const directContent = response.choices?.[0]?.message?.content?.trim(); if (directContent) { @@ -1060,6 +1089,24 @@ export class ListsService { } } + const outputText = response.output_text?.trim(); + + if (outputText) { + return outputText; + } + + for (const output of [...(response.outputs ?? [])].reverse()) { + const content = + typeof output.content === 'string' ? output.content.trim() : ''; + + if ( + content && + (output.role === 'assistant' || output.type?.includes('message')) + ) { + return content; + } + } + return null; } @@ -1129,7 +1176,10 @@ export class ListsService { return value.trim().replace(/\s+/g, ' ').toLowerCase(); } - private compactText(value: string | undefined, maxLength: number): string | undefined { + private compactText( + value: string | undefined, + maxLength: number, + ): string | undefined { const compacted = value?.replace(/\s+/g, ' ').trim(); if (!compacted) {