chat
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 }>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<AssistantChatResponse> {
|
||||
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<MistralAgentCompletionResponse> {
|
||||
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<UserListItem>,
|
||||
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)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<ListsService, 'listLists' | 'createList' | 'addItem'>;
|
||||
let listTemplatesService: Pick<ListTemplatesService, 'listTemplates'>;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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<AssistantConversationMessage[]>([
|
||||
{
|
||||
@@ -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<AssistantPageContext> {
|
||||
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<AssistantPageContext>({ 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<AssistantPageContext>({ page: 'unknown', route })),
|
||||
);
|
||||
}
|
||||
|
||||
return of({ page: 'unknown', route });
|
||||
}
|
||||
|
||||
protected handleEnter(event: Event): void {
|
||||
const keyboardEvent = event as KeyboardEvent;
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user