diff --git a/listify-api/README.md b/listify-api/README.md index f5da11c..3e160ff 100644 --- a/listify-api/README.md +++ b/listify-api/README.md @@ -153,6 +153,38 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den - Output enthaelt `list` mit der aktualisierten Liste. +- `create_template` + - Erstellt ein neues Template fuer den angemeldeten User. + - Input: + +```json +{ + "name": "Sommerurlaub", + "description": "Wiederverwendbare Packvorlage", + "kind": "packing", + "items": [ + { "title": "Pass", "required": true }, + { "title": "Tickets", "notes": "Digital und offline sichern" } + ] +} +``` + + - 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. + - Input: + +```json +{ + "templateId": "template-id", + "title": "Sonnencreme", + "required": false +} +``` + + - Output enthaelt `template` mit dem aktualisierten Template. + ### Minimaler MCP-Request Ein MCP-Client uebernimmt normalerweise Initialize, Session-Header und Tool-Calls selbst. Fuer eigene Tests sieht ein Tool-Call nach erfolgreicher Initialisierung sinngemaess so aus: @@ -173,7 +205,7 @@ Ein MCP-Client uebernimmt normalerweise Initialize, Session-Header und Tool-Call } ``` -Wichtig: Der aktuelle Ausbau erlaubt Erstellen von Listen und Hinzufuegen von Items. Aendern, Abhaken, Loeschen und Teilen von Listen ist ueber MCP noch nicht freigegeben. +Wichtig: Der aktuelle Ausbau erlaubt Erstellen von Listen und Templates sowie Hinzufuegen von Items. Aendern, Abhaken, Loeschen und Teilen ist ueber MCP noch nicht freigegeben. ## Deployment diff --git a/listify-api/src/assistant/assistant.service.spec.ts b/listify-api/src/assistant/assistant.service.spec.ts index 4c8aea9..34034ce 100644 --- a/listify-api/src/assistant/assistant.service.spec.ts +++ b/listify-api/src/assistant/assistant.service.spec.ts @@ -114,6 +114,92 @@ describe('AssistantService', () => { ); }); + it('adds a normalized list context system message when context is present', async () => { + const providerResponse = { + choices: [ + { + message: { + content: 'Ich nutze diese Liste.', + }, + }, + ], + }; + mockMistralResponse(providerResponse); + + await service.chat('user-1', { + messages: [{ role: 'user', content: 'Fuege hier Brot hinzu' }], + context: { + page: 'list_detail', + route: '/lists/list-1', + list: { + id: 'list-1', + ownerId: 'user-1', + ownerName: 'Ada', + ownerEmail: 'ada@example.com', + accessRole: 'owner', + name: 'Einkauf', + description: 'Wochenende', + kind: 'shopping', + items: [ + { + id: 'item-1', + title: 'Milch', + notes: '1,5 Prozent', + quantity: 2, + required: true, + checked: false, + checkedByUserId: 'user-2', + checkedByName: 'Grace', + position: 0, + createdAt: '2026-06-12T00:00:00.000Z', + updatedAt: '2026-06-12T00:00:00.000Z', + }, + ], + collaborators: [ + { + id: 'user-2', + email: 'grace@example.com', + role: 'collaborator', + }, + ], + createdAt: '2026-06-12T00:00:00.000Z', + updatedAt: '2026-06-12T00:00:00.000Z', + }, + }, + }); + + const payload = getMistralRequestPayload(); + const contextMessage = payload.messages.at(-2); + const contextContent = contextMessage?.content ?? ''; + + expect(contextMessage).toEqual( + expect.objectContaining({ + role: 'system', + content: expect.stringContaining('Aktueller Listify-Kontext:'), + }), + ); + expect(contextContent).toContain( + 'Der User befindet sich auf einer Listendetailseite.', + ); + expect(contextContent).toContain('Route: /lists/list-1'); + expect(contextContent).toContain( + 'Offene Liste: Einkauf (ID: list-1, Typ: shopping)', + ); + expect(contextContent).toContain( + '- Milch (ID: item-1, Menge: 2, Pflicht: ja, Erledigt: nein, Notizen: 1,5 Prozent)', + ); + expect(contextContent).toContain( + 'bezieht sich das auf die Liste mit ID list-1.', + ); + 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: 'benutze immer den listify connector', + }); + }); + it('logs full failed provider responses before throwing', async () => { const providerResponse = { message: 'connector failed', @@ -263,3 +349,18 @@ function mockMistralResponse( text: async () => JSON.stringify(response), } as Response); } + +function getMistralRequestPayload(): { + messages: Array<{ role: string; content: string }>; +} { + const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? []; + const body = init?.body; + + if (typeof body !== 'string') { + throw new Error('Expected Mistral request body to be JSON.'); + } + + return JSON.parse(body) as { + messages: Array<{ role: string; content: string }>; + }; +} diff --git a/listify-api/src/assistant/assistant.service.ts b/listify-api/src/assistant/assistant.service.ts index 5e14d3a..0a2e10d 100644 --- a/listify-api/src/assistant/assistant.service.ts +++ b/listify-api/src/assistant/assistant.service.ts @@ -6,16 +6,61 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; import { Repository } from 'typeorm'; -import { UserList } from '../list-templates/list-template.types'; +import { + ListTemplate, + UserList, + UserListItem, +} from '../list-templates/list-template.types'; import { ListRealtimeService } from '../lists/list-realtime.service'; import { AssistantChatLogEntity } from './assistant-chat-log.entity'; import { AssistantAction, AssistantChatMessage, + AssistantPageContext, AssistantChatRequest, AssistantChatResponse, } from './assistant.types'; +type MistralMessage = AssistantChatMessage | { role: 'system'; content: string }; + +interface NormalizedContextItem { + id: string; + title: string; + notes?: string; + quantity?: number; + required?: boolean; + checked?: boolean; +} + +interface NormalizedContextList { + id: string; + name: string; + kind: string; + description?: string; + items: NormalizedContextItem[]; + omittedItems: number; +} + +interface NormalizedContextTemplate { + id: string; + name: string; + kind: string; + description?: string; + items: NormalizedContextItem[]; + omittedItems: number; +} + +type NormalizedAssistantPageContext = + | { page: 'lists_overview'; route: string } + | { page: 'list_detail'; route: string; list: NormalizedContextList } + | { page: 'templates_overview'; route: string } + | { + page: 'template_detail'; + route: string; + template: NormalizedContextTemplate; + } + | { page: 'unknown'; route: string }; + interface MistralAgentCompletionResponse { choices?: Array<{ message?: { @@ -55,7 +100,8 @@ export class AssistantService { request: AssistantChatRequest, ): Promise { const messages = this.normalizeMessages(request.messages); - const response = await this.callMistralAgent(userId, messages); + const context = this.normalizeContext(request.context); + const response = await this.callMistralAgent(userId, messages, context); const content = this.extractAssistantContent(response); const actions = this.extractActions(response); @@ -79,6 +125,7 @@ export class AssistantService { private async callMistralAgent( userId: string, messages: AssistantChatMessage[], + context: NormalizedAssistantPageContext | null, ): Promise { const apiKey = process.env.MISTRAL_API_KEY; const agentId = process.env.MISTRAL_AGENT_ID; @@ -91,16 +138,18 @@ export class AssistantService { throw new ServiceUnavailableException('Mistral agent id is not configured.'); } + const contextMessage = this.createContextSystemMessage(context); const requestPayload = { agent_id: agentId, messages: [ ...messages, - { "role": "system", "content": "benutze immer den listify connector"} + ...(contextMessage ? [contextMessage] : []), + { role: 'system', content: 'benutze immer den listify connector' }, ], tools: [ { - "type": "connector", - "connector_id": "listify" + type: 'connector', + connector_id: 'listify', } ], stream: false, @@ -356,4 +405,246 @@ export class AssistantService { return { role: message.role, content }; }); } + + private normalizeContext( + context: AssistantPageContext | undefined, + ): NormalizedAssistantPageContext | null { + if (!context || typeof context !== 'object') { + return null; + } + + const route = this.compactString(context.route, 240) ?? ''; + + if (context.page === 'lists_overview') { + return { page: 'lists_overview', route }; + } + + if (context.page === 'templates_overview') { + return { page: 'templates_overview', route }; + } + + if (context.page === 'unknown') { + return { page: 'unknown', route }; + } + + if (context.page === 'list_detail') { + const list = this.normalizeListContext(context.list); + + return list ? { page: 'list_detail', route, list } : null; + } + + if (context.page === 'template_detail') { + const template = this.normalizeTemplateContext(context.template); + + return template ? { page: 'template_detail', route, template } : null; + } + + return null; + } + + private normalizeListContext(list: UserList): NormalizedContextList | null { + if (!list || typeof list !== 'object' || Array.isArray(list)) { + return null; + } + + const id = this.compactString(list.id, 120); + const name = this.compactString(list.name, 160); + const kind = this.compactString(list.kind, 80); + + if (!id || !name || !kind || !Array.isArray(list.items)) { + return null; + } + + const items = list.items + .slice(0, 40) + .map((item) => this.normalizeItemContext(item, true)) + .filter((item): item is NormalizedContextItem => item !== null); + + return { + id, + name, + kind, + description: this.compactString(list.description, 240), + items, + omittedItems: Math.max(0, list.items.length - items.length), + }; + } + + private normalizeTemplateContext( + template: ListTemplate, + ): NormalizedContextTemplate | null { + if (!template || typeof template !== 'object' || Array.isArray(template)) { + return null; + } + + const id = this.compactString(template.id, 120); + const name = this.compactString(template.name, 160); + const kind = this.compactString(template.kind, 80); + + if (!id || !name || !kind || !Array.isArray(template.items)) { + return null; + } + + const items = template.items + .slice(0, 40) + .map((item) => this.normalizeItemContext(item, false)) + .filter((item): item is NormalizedContextItem => item !== null); + + return { + id, + name, + kind, + description: this.compactString(template.description, 240), + items, + omittedItems: Math.max(0, template.items.length - items.length), + }; + } + + private normalizeItemContext( + item: Partial, + includeChecked: boolean, + ): NormalizedContextItem | null { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return null; + } + + const id = this.compactString(item.id, 120); + const title = this.compactString(item.title, 160); + + if (!id || !title) { + return null; + } + + return { + id, + title, + notes: this.compactString(item.notes, 220), + quantity: typeof item.quantity === 'number' ? item.quantity : undefined, + required: typeof item.required === 'boolean' ? item.required : undefined, + checked: + includeChecked && typeof item.checked === 'boolean' + ? item.checked + : undefined, + }; + } + + private createContextSystemMessage( + context: NormalizedAssistantPageContext | null, + ): MistralMessage | null { + if (!context) { + return null; + } + + const lines = ['Aktueller Listify-Kontext:']; + + if (context.page === 'lists_overview') { + lines.push(`Der User befindet sich auf der Listenuebersicht.`); + lines.push(`Route: ${context.route}`); + return { role: 'system', content: lines.join('\n') }; + } + + if (context.page === 'templates_overview') { + lines.push(`Der User befindet sich auf der Vorlagenuebersicht.`); + lines.push(`Route: ${context.route}`); + return { role: 'system', content: lines.join('\n') }; + } + + if (context.page === 'unknown') { + lines.push(`Die aktuelle Seite ist nicht eindeutig zugeordnet.`); + lines.push(`Route: ${context.route}`); + return { role: 'system', content: lines.join('\n') }; + } + + if (context.page === 'list_detail') { + lines.push(`Der User befindet sich auf einer Listendetailseite.`); + lines.push(`Route: ${context.route}`); + lines.push(this.formatListContext(context.list)); + lines.push( + `Wenn der User "diese Liste", "hier" oder aehnliche Verweise nutzt, bezieht sich das auf die Liste mit ID ${context.list.id}.`, + ); + return { role: 'system', content: lines.join('\n') }; + } + + lines.push(`Der User befindet sich auf einer Vorlagendetailseite.`); + lines.push(`Route: ${context.route}`); + lines.push(this.formatTemplateContext(context.template)); + lines.push( + `Wenn der User "diese Vorlage", "hier" oder aehnliche Verweise nutzt, bezieht sich das auf die Vorlage mit ID ${context.template.id}.`, + ); + + return { role: 'system', content: lines.join('\n') }; + } + + private formatListContext(list: NormalizedContextList): string { + return [ + `Offene Liste: ${list.name} (ID: ${list.id}, Typ: ${list.kind})`, + ...(list.description ? [`Beschreibung: ${list.description}`] : []), + ...this.formatItems(list.items, list.omittedItems, true), + ].join('\n'); + } + + private formatTemplateContext(template: NormalizedContextTemplate): string { + return [ + `Offene Vorlage: ${template.name} (ID: ${template.id}, Typ: ${template.kind})`, + ...(template.description + ? [`Beschreibung: ${template.description}`] + : []), + ...this.formatItems(template.items, template.omittedItems, false), + ].join('\n'); + } + + private formatItems( + items: NormalizedContextItem[], + omittedItems: number, + includeChecked: boolean, + ): string[] { + if (items.length === 0) { + return ['Items: keine']; + } + + const lines = ['Items:']; + + for (const item of items) { + const parts = [ + `ID: ${item.id}`, + typeof item.quantity === 'number' ? `Menge: ${item.quantity}` : null, + typeof item.required === 'boolean' + ? `Pflicht: ${item.required ? 'ja' : 'nein'}` + : null, + includeChecked && typeof item.checked === 'boolean' + ? `Erledigt: ${item.checked ? 'ja' : 'nein'}` + : null, + item.notes ? `Notizen: ${item.notes}` : null, + ].filter((part): part is string => part !== null); + + lines.push(`- ${item.title} (${parts.join(', ')})`); + } + + if (omittedItems > 0) { + lines.push(`- ${omittedItems} weitere Eintraege ausgelassen.`); + } + + return lines; + } + + private compactString( + value: string | undefined, + maxLength: number, + ): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const compacted = value.replace(/\s+/g, ' ').trim(); + + if (!compacted) { + return undefined; + } + + if (compacted.length <= maxLength) { + return compacted; + } + + return `${compacted.slice(0, maxLength - 3)}...`; + } } diff --git a/listify-api/src/assistant/assistant.types.ts b/listify-api/src/assistant/assistant.types.ts index 0e56cea..be03e08 100644 --- a/listify-api/src/assistant/assistant.types.ts +++ b/listify-api/src/assistant/assistant.types.ts @@ -1,4 +1,8 @@ -import { ListTemplateKind, UserList } from '../list-templates/list-template.types'; +import { + ListTemplate, + ListTemplateKind, + UserList, +} from '../list-templates/list-template.types'; export type AssistantMessageRole = 'user' | 'assistant'; @@ -9,8 +13,16 @@ export interface AssistantChatMessage { export interface AssistantChatRequest { messages?: AssistantChatMessage[]; + context?: AssistantPageContext; } +export type AssistantPageContext = + | { page: 'lists_overview'; route: string } + | { page: 'list_detail'; route: string; list: UserList } + | { page: 'templates_overview'; route: string } + | { page: 'template_detail'; route: string; template: ListTemplate } + | { page: 'unknown'; route: string }; + export type AssistantAction = | { type: 'list.created'; diff --git a/listify-api/src/mcp/mcp-server.service.spec.ts b/listify-api/src/mcp/mcp-server.service.spec.ts index 6e6a872..5df19ab 100644 --- a/listify-api/src/mcp/mcp-server.service.spec.ts +++ b/listify-api/src/mcp/mcp-server.service.spec.ts @@ -1,12 +1,15 @@ import { ListTemplatesService } from '../list-templates/list-templates.service'; -import { UserList } from '../list-templates/list-template.types'; +import { ListTemplate, UserList } from '../list-templates/list-template.types'; import { ListsService } from '../lists/lists.service'; import { ListSuggestionAgentService } from './list-suggestion-agent.service'; import { McpServerService } from './mcp-server.service'; describe('McpServerService', () => { let listsService: Pick; - let listTemplatesService: Pick; + let listTemplatesService: Pick< + ListTemplatesService, + 'listTemplates' | 'createTemplate' | 'addItem' + >; let listSuggestionAgentService: Pick< ListSuggestionAgentService, 'suggestLists' @@ -21,6 +24,8 @@ describe('McpServerService', () => { }; listTemplatesService = { listTemplates: jest.fn(), + createTemplate: jest.fn(), + addItem: jest.fn(), }; listSuggestionAgentService = { suggestLists: jest.fn(), @@ -142,6 +147,87 @@ describe('McpServerService', () => { }); expect(result.structuredContent).toEqual({ list: updatedList }); }); + + it('registers create_template as a write tool and creates initial items', async () => { + const createdTemplate = template({ + id: 'template-1', + name: 'Urlaub', + items: ['Pass', 'Tickets'], + }); + jest + .mocked(listTemplatesService.createTemplate) + .mockResolvedValue(createdTemplate); + + const tool = toolFrom(service.createServer('user-1'), 'create_template'); + const result = await tool.handler( + { + name: 'Urlaub', + description: 'Packvorlage', + kind: 'packing', + items: [ + { title: 'Pass', required: true }, + { title: 'Tickets', notes: 'Digital sichern', required: false }, + ], + }, + {} as never, + ); + + expect(tool.annotations).toEqual( + expect.objectContaining({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }), + ); + expect(listTemplatesService.createTemplate).toHaveBeenCalledWith('user-1', { + name: 'Urlaub', + description: 'Packvorlage', + kind: 'packing', + items: [ + { title: 'Pass', required: true }, + { title: 'Tickets', notes: 'Digital sichern', required: false }, + ], + }); + expect(result.structuredContent).toEqual({ template: createdTemplate }); + }); + + it('registers add_template_item as a write tool and adds an item', async () => { + const updatedTemplate = template({ + id: 'template-1', + name: 'Urlaub', + items: ['Pass'], + }); + jest.mocked(listTemplatesService.addItem).mockResolvedValue(updatedTemplate); + + const tool = toolFrom(service.createServer('user-1'), 'add_template_item'); + const result = await tool.handler( + { + templateId: 'template-1', + title: 'Pass', + quantity: 1, + }, + {} as never, + ); + + expect(tool.annotations).toEqual( + expect.objectContaining({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }), + ); + expect(listTemplatesService.addItem).toHaveBeenCalledWith( + 'user-1', + 'template-1', + { + title: 'Pass', + notes: undefined, + quantity: 1, + required: undefined, + }, + ); + expect(result.structuredContent).toEqual({ template: updatedTemplate }); + }); }); function toolFrom(server: object, name: string) { @@ -181,6 +267,29 @@ function list(options: { }; } +function template(options: { + id: string; + name: string; + items?: string[]; +}): ListTemplate { + return { + id: options.id, + ownerId: 'user-1', + name: options.name, + kind: 'packing', + items: (options.items ?? []).map((title, position) => ({ + id: `template-item-${position}`, + title, + required: true, + position, + createdAt: now(), + updatedAt: now(), + })), + createdAt: now(), + updatedAt: now(), + }; +} + function now(): string { return new Date(0).toISOString(); } diff --git a/listify-api/src/mcp/mcp-server.service.ts b/listify-api/src/mcp/mcp-server.service.ts index 947066a..22de5eb 100644 --- a/listify-api/src/mcp/mcp-server.service.ts +++ b/listify-api/src/mcp/mcp-server.service.ts @@ -18,6 +18,24 @@ const listItemInputSchema = { .optional() .describe('Whether the item is required. Defaults to true.'), }; +const templateItemInputSchema = { + title: z.string().trim().min(1).describe('Template item title.'), + notes: z + .string() + .trim() + .min(1) + .optional() + .describe('Optional template item notes.'), + quantity: z + .number() + .positive() + .optional() + .describe('Optional template item quantity.'), + required: z + .boolean() + .optional() + .describe('Whether the template item is required. Defaults to true.'), +}; @Injectable() export class McpServerService { @@ -225,6 +243,79 @@ export class McpServerService { }, ); + server.registerTool( + 'create_template', + { + title: 'Create template', + description: + 'Creates a new list template for the authenticated user and optionally adds template items.', + inputSchema: { + name: z.string().trim().min(1).describe('Template name.'), + description: z + .string() + .trim() + .min(1) + .optional() + .describe('Optional template description.'), + kind: listKindSchema.describe('Optional template kind.'), + items: z + .array(z.object(templateItemInputSchema)) + .max(50) + .optional() + .describe('Optional initial template items.'), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ name, description, kind, items = [] }) => { + const template = await this.listTemplatesService.createTemplate(userId, { + name, + description, + kind: kind as ListTemplateKind | undefined, + items, + }); + + return this.toToolResult({ template }); + }, + ); + + server.registerTool( + 'add_template_item', + { + title: 'Add template item', + description: + 'Adds an item to an existing list template owned by the authenticated user.', + inputSchema: { + templateId: z.string().trim().min(1).describe('Target template id.'), + ...templateItemInputSchema, + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ templateId, title, notes, quantity, required }) => { + const template = await this.listTemplatesService.addItem( + userId, + templateId, + { + title, + notes, + quantity, + required, + }, + ); + + return this.toToolResult({ template }); + }, + ); + return server; } diff --git a/listify-client/src/app/assistant/assistant-chat.component.spec.ts b/listify-client/src/app/assistant/assistant-chat.component.spec.ts new file mode 100644 index 0000000..f10f790 --- /dev/null +++ b/listify-client/src/app/assistant/assistant-chat.component.spec.ts @@ -0,0 +1,147 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { Mock, vi } from 'vitest'; +import { UserList } from '../lists/lists.models'; +import { ListsService } from '../lists/lists.service'; +import { TemplatesService } from '../templates/templates.service'; +import { AssistantChatComponent } from './assistant-chat.component'; +import { AssistantService } from './assistant.service'; + +@Component({ + standalone: true, + template: '', +}) +class EmptyRouteComponent {} + +describe('AssistantChatComponent', () => { + let assistantService: { chat: Mock }; + let listsService: { getList: Mock }; + let templatesService: { getTemplate: Mock }; + let router: Router; + + beforeEach(async () => { + assistantService = { + chat: vi.fn().mockReturnValue( + of({ + message: { role: 'assistant', content: 'ok' }, + actions: [], + }), + ), + }; + listsService = { + getList: vi.fn(), + }; + templatesService = { + getTemplate: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [AssistantChatComponent], + providers: [ + provideRouter([ + { path: 'lists', component: EmptyRouteComponent }, + { path: 'lists/:listId', component: EmptyRouteComponent }, + { path: 'templates', component: EmptyRouteComponent }, + { path: 'templates/:templateId', component: EmptyRouteComponent }, + ]), + { provide: AssistantService, useValue: assistantService }, + { provide: ListsService, useValue: listsService }, + { provide: TemplatesService, useValue: templatesService }, + ], + }).compileComponents(); + + router = TestBed.inject(Router); + }); + + it('sends list detail context with the current list', async () => { + const list = createList(); + listsService.getList.mockReturnValue(of(list)); + await router.navigateByUrl('/lists/list-1'); + const fixture = TestBed.createComponent(AssistantChatComponent); + const component = fixture.componentInstance as unknown as { + draft: { set(value: string): void }; + send(): void; + }; + + component.draft.set('Was ist hier offen?'); + component.send(); + + expect(listsService.getList).toHaveBeenCalledOnce(); + expect(listsService.getList).toHaveBeenCalledWith('list-1'); + expect(assistantService.chat).toHaveBeenCalledWith({ + messages: [ + { + role: 'assistant', + content: + 'Hallo, ich bin dein Listify-Assistent. Was soll ich vorbereiten?', + }, + { role: 'user', content: 'Was ist hier offen?' }, + ], + context: { + page: 'list_detail', + route: '/lists/list-1', + list, + }, + }); + }); + + it('falls back to route context when list context loading fails', async () => { + listsService.getList.mockReturnValue(throwError(() => new Error('not found'))); + await router.navigateByUrl('/lists/list-1'); + const fixture = TestBed.createComponent(AssistantChatComponent); + const component = fixture.componentInstance as unknown as { + draft: { set(value: string): void }; + send(): void; + }; + + component.draft.set('Was ist hier offen?'); + component.send(); + + expect(assistantService.chat).toHaveBeenCalledWith( + expect.objectContaining({ + context: { + page: 'unknown', + route: '/lists/list-1', + }, + }), + ); + }); + + it('sends overview context without loading a list', async () => { + await router.navigateByUrl('/lists'); + const fixture = TestBed.createComponent(AssistantChatComponent); + const component = fixture.componentInstance as unknown as { + draft: { set(value: string): void }; + send(): void; + }; + + component.draft.set('Welche Listen habe ich?'); + component.send(); + + expect(listsService.getList).not.toHaveBeenCalled(); + expect(assistantService.chat).toHaveBeenCalledWith( + expect.objectContaining({ + context: { + page: 'lists_overview', + route: '/lists', + }, + }), + ); + }); +}); + +function createList(): UserList { + return { + 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', + }; +} diff --git a/listify-client/src/app/assistant/assistant-chat.component.ts b/listify-client/src/app/assistant/assistant-chat.component.ts index 301dcbb..2e293ca 100644 --- a/listify-client/src/app/assistant/assistant-chat.component.ts +++ b/listify-client/src/app/assistant/assistant-chat.component.ts @@ -1,6 +1,6 @@ import { Component, computed, inject, signal } from '@angular/core'; -import { RouterLink } from '@angular/router'; -import { finalize } from 'rxjs'; +import { Router, RouterLink } from '@angular/router'; +import { Observable, catchError, finalize, map, of, switchMap } from 'rxjs'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; @@ -10,9 +10,12 @@ import { getAuthErrorMessage } from '../auth/error-message'; import { AssistantAction, AssistantChatMessage, + AssistantPageContext, AssistantConversationMessage, } from './assistant.models'; import { AssistantService } from './assistant.service'; +import { ListsService } from '../lists/lists.service'; +import { TemplatesService } from '../templates/templates.service'; @Component({ selector: 'app-assistant-chat', @@ -29,6 +32,9 @@ import { AssistantService } from './assistant.service'; }) export class AssistantChatComponent { private readonly assistantService = inject(AssistantService); + private readonly listsService = inject(ListsService); + private readonly router = inject(Router); + private readonly templatesService = inject(TemplatesService); protected readonly messages = signal([ { @@ -61,14 +67,23 @@ export class AssistantChatComponent { this.sending.set(true); this.errorMessage.set(null); - this.assistantService - .chat({ - messages: nextMessages.map((message): AssistantChatMessage => ({ - role: message.role, - content: message.content, - })), - }) - .pipe(finalize(() => this.sending.set(false))) + const requestMessages = nextMessages.map( + (message): AssistantChatMessage => ({ + role: message.role, + content: message.content, + }), + ); + + this.resolveContext() + .pipe( + switchMap((context) => + this.assistantService.chat({ + messages: requestMessages, + context, + }), + ), + finalize(() => this.sending.set(false)), + ) .subscribe({ next: (response) => { this.messages.update((messages) => [ @@ -85,6 +100,52 @@ export class AssistantChatComponent { }); } + private resolveContext(): Observable { + const route = this.router.url || '/'; + const path = route.split(/[?#]/)[0] || '/'; + const segments = path.split('/').filter(Boolean); + + if (segments.length === 1 && segments[0] === 'lists') { + return of({ page: 'lists_overview', route }); + } + + if ( + segments.length === 2 && + segments[0] === 'lists' && + segments[1] !== 'new' + ) { + return this.listsService.getList(segments[1]).pipe( + map((list): AssistantPageContext => ({ + page: 'list_detail', + route, + list, + })), + catchError(() => of({ page: 'unknown', route })), + ); + } + + if (segments.length === 1 && segments[0] === 'templates') { + return of({ page: 'templates_overview', route }); + } + + if ( + segments.length === 2 && + segments[0] === 'templates' && + segments[1] !== 'new' + ) { + return this.templatesService.getTemplate(segments[1]).pipe( + map((template): AssistantPageContext => ({ + page: 'template_detail', + route, + template, + })), + catchError(() => of({ page: 'unknown', route })), + ); + } + + return of({ page: 'unknown', route }); + } + protected handleEnter(event: Event): void { const keyboardEvent = event as KeyboardEvent; diff --git a/listify-client/src/app/assistant/assistant.models.ts b/listify-client/src/app/assistant/assistant.models.ts index 09e0c47..430b47f 100644 --- a/listify-client/src/app/assistant/assistant.models.ts +++ b/listify-client/src/app/assistant/assistant.models.ts @@ -1,4 +1,5 @@ import { UserList } from '../lists/lists.models'; +import { ListTemplate } from '../templates/templates.models'; export type AssistantMessageRole = 'user' | 'assistant'; @@ -22,8 +23,16 @@ export type AssistantAction = export interface AssistantChatRequest { messages: AssistantChatMessage[]; + context?: AssistantPageContext; } +export type AssistantPageContext = + | { page: 'lists_overview'; route: string } + | { page: 'list_detail'; route: string; list: UserList } + | { page: 'templates_overview'; route: string } + | { page: 'template_detail'; route: string; template: ListTemplate } + | { page: 'unknown'; route: string }; + export interface AssistantChatResponse { message: AssistantChatMessage; actions: AssistantAction[]; diff --git a/readme.md b/readme.md index 74fce44..49f35e6 100644 --- a/readme.md +++ b/readme.md @@ -24,5 +24,7 @@ Verfuegbare MCP-Tools: - `suggest_lists`: erzeugt strukturierte Vorschlaege fuer neue Listen, schreibt aber nichts in die Datenbank. - `create_list`: erstellt eine neue Liste mit optionalen Start-Items. - `add_list_item`: fuegt ein Item zu einer bestehenden Liste hinzu. +- `create_template`: erstellt ein neues Template mit optionalen Start-Items. +- `add_template_item`: fuegt ein Item zu einem bestehenden Template hinzu. Weitere Details und Beispiel-Requests stehen in `listify-api/README.md`.