This commit is contained in:
Bastian Wagner
2026-06-12 10:31:01 +02:00
parent 4dec991c4a
commit c40cb030f6
18 changed files with 1143 additions and 5 deletions

View File

@@ -18,6 +18,9 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
# Browser-URL, unter der der Container erreichbar ist.
CLIENT_URL=http://localhost:8080
MISTRAL_API_KEY=
MISTRAL_MODEL=mistral-small-latest
MAIL_ENABLED=true
SMTP_HOST=host.docker.internal
SMTP_PORT=1025

View File

@@ -15,6 +15,9 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
CLIENT_URL=http://localhost:4200
MISTRAL_API_KEY=
MISTRAL_MODEL=mistral-small-latest
MAIL_ENABLED=true
SMTP_HOST=localhost
SMTP_PORT=1025

View File

@@ -44,6 +44,17 @@ $ npm run start:dev
$ npm run start:prod
```
## Mistral assistant
The in-app assistant calls Mistral from the API server. Configure the key in the API environment, never in the Angular client:
```bash
MISTRAL_API_KEY=your-mistral-api-key
MISTRAL_MODEL=mistral-small-latest
```
The authenticated frontend calls `POST /api/assistant/chat`; the API then calls Mistral and may execute allowed Listify actions such as creating lists or adding list items.
## Run tests
```bash

View File

@@ -4,6 +4,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AssistantModule } from './assistant/assistant.module';
import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module';
import { ListTemplatesModule } from './list-templates/list-templates.module';
@@ -52,6 +53,7 @@ import { DatabaseLogger } from './database/database.logger';
},
}),
EventEmitterModule.forRoot(),
AssistantModule,
AuditModule,
AuthModule,
MailModule,

View File

@@ -0,0 +1,32 @@
import {
Body,
Controller,
Post,
Req,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AssistantService } from './assistant.service';
import type { AuthenticatedRequest } from '../auth/auth.types';
import type { AssistantChatRequest } from './assistant.types';
@Controller('assistant')
@UseGuards(JwtAuthGuard)
export class AssistantController {
constructor(private readonly assistantService: AssistantService) {}
@Post('chat')
chat(
@Req() request: AuthenticatedRequest,
@Body() body: AssistantChatRequest,
) {
const userId = request.user?.sub;
if (!userId) {
throw new UnauthorizedException('Authenticated user is required.');
}
return this.assistantService.chat(userId, body);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ListsModule } from '../lists/lists.module';
import { AssistantController } from './assistant.controller';
import { AssistantService } from './assistant.service';
@Module({
imports: [AuthModule, ListsModule],
controllers: [AssistantController],
providers: [AssistantService],
})
export class AssistantModule {}

View File

@@ -0,0 +1,220 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { UserList } from '../list-templates/list-template.types';
import { ListsService } from '../lists/lists.service';
import { AssistantService } from './assistant.service';
describe('AssistantService', () => {
const originalFetch = global.fetch;
const originalApiKey = process.env.MISTRAL_API_KEY;
let listsService: Pick<ListsService, 'listLists' | 'createList' | 'addItem'>;
let service: AssistantService;
beforeEach(() => {
process.env.MISTRAL_API_KEY = 'test-key';
global.fetch = jest.fn();
listsService = {
listLists: jest.fn(),
createList: jest.fn(),
addItem: jest.fn(),
};
service = new AssistantService(listsService as ListsService);
});
afterEach(() => {
global.fetch = originalFetch;
process.env.MISTRAL_API_KEY = originalApiKey;
delete process.env.MISTRAL_MODEL;
});
it('returns a plain assistant response without tool calls', async () => {
mockMistralResponse({
choices: [{ message: { content: 'Klar, ich helfe dir.' } }],
});
const result = await service.chat('user-1', {
messages: [{ role: 'user', content: 'Hallo' }],
});
expect(result).toEqual({
message: { role: 'assistant', content: 'Klar, ich helfe dir.' },
actions: [],
});
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('executes create_list tool calls and returns the final assistant response', async () => {
const createdList = list({ id: 'list-1', name: 'Sommerurlaub' });
const completedList = list({
id: 'list-1',
name: 'Sommerurlaub',
items: ['Pass'],
});
jest.mocked(listsService.createList).mockResolvedValue(createdList);
jest.mocked(listsService.addItem).mockResolvedValue(completedList);
mockMistralResponse(
{
choices: [
{
message: {
content: '',
tool_calls: [
{
id: 'call-1',
type: 'function',
function: {
name: 'create_list',
arguments: JSON.stringify({
name: 'Sommerurlaub',
kind: 'packing',
items: [{ title: 'Pass' }],
}),
},
},
],
},
},
],
},
{
choices: [
{
message: {
content: 'Ich habe die Packliste Sommerurlaub erstellt.',
},
},
],
},
);
const result = await service.chat('user-1', {
messages: [{ role: 'user', content: 'Erstelle eine Packliste' }],
});
expect(listsService.createList).toHaveBeenCalledWith('user-1', {
name: 'Sommerurlaub',
description: undefined,
kind: 'packing',
});
expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', {
title: 'Pass',
notes: undefined,
quantity: undefined,
required: undefined,
});
expect(result.actions).toEqual([
{
type: 'list.created',
listId: 'list-1',
list: completedList,
},
]);
expect(result.message.content).toBe(
'Ich habe die Packliste Sommerurlaub erstellt.',
);
});
it('executes add_list_item tool calls', async () => {
const updatedList = list({
id: 'list-1',
name: 'Einkauf',
items: ['Milch'],
});
jest.mocked(listsService.addItem).mockResolvedValue(updatedList);
mockMistralResponse(
{
choices: [
{
message: {
content: '',
tool_calls: [
{
id: 'call-1',
type: 'function',
function: {
name: 'add_list_item',
arguments: JSON.stringify({
listId: 'list-1',
title: 'Milch',
quantity: 2,
}),
},
},
],
},
},
],
},
{
choices: [{ message: { content: 'Milch wurde hinzugefuegt.' } }],
},
);
const result = await service.chat('user-1', {
messages: [{ role: 'user', content: 'Fuege Milch hinzu' }],
});
expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', {
title: 'Milch',
notes: undefined,
quantity: 2,
required: undefined,
});
expect(result.actions[0]).toEqual({
type: 'list.item_added',
listId: 'list-1',
itemTitle: 'Milch',
list: updatedList,
});
});
it('fails clearly when the api key is missing', async () => {
delete process.env.MISTRAL_API_KEY;
await expect(
service.chat('user-1', {
messages: [{ role: 'user', content: 'Hallo' }],
}),
).rejects.toThrow(ServiceUnavailableException);
});
});
function mockMistralResponse(...responses: object[]): void {
jest.mocked(global.fetch).mockImplementation(async () => {
const response = responses.shift() ?? responses[responses.length - 1];
return {
ok: true,
json: async () => response,
} as Response;
});
}
function list(options: {
id: string;
name: string;
items?: string[];
}): UserList {
return {
id: options.id,
ownerId: 'user-1',
accessRole: 'owner',
name: options.name,
kind: 'custom',
items: (options.items ?? []).map((title, position) => ({
id: `item-${position}`,
title,
required: true,
checked: false,
position,
createdAt: now(),
updatedAt: now(),
})),
collaborators: [],
createdAt: now(),
updatedAt: now(),
};
}
function now(): string {
return new Date(0).toISOString();
}

View File

@@ -0,0 +1,429 @@
import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { ListTemplateKind } from '../list-templates/list-template.types';
import { ListsService } from '../lists/lists.service';
import {
AddListItemToolInput,
AssistantAction,
AssistantChatMessage,
AssistantChatRequest,
AssistantChatResponse,
CreateListToolInput,
} from './assistant.types';
type MistralRole = 'system' | 'user' | 'assistant' | 'tool';
interface MistralMessage {
role: MistralRole;
content: string | null;
tool_call_id?: string;
tool_calls?: MistralToolCall[];
}
interface MistralToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
interface MistralChatResponse {
choices?: Array<{
message?: {
content?: string | null;
tool_calls?: MistralToolCall[];
};
}>;
}
@Injectable()
export class AssistantService {
private readonly endpoint = 'https://api.mistral.ai/v1/chat/completions';
constructor(private readonly listsService: ListsService) {}
async chat(
userId: string,
request: AssistantChatRequest,
): Promise<AssistantChatResponse> {
const messages = this.normalizeMessages(request.messages);
const firstResponse = await this.callMistral([
this.systemMessage(),
...messages.map((message) => ({
role: message.role,
content: message.content,
})),
]);
const firstMessage = this.extractMessage(firstResponse);
const actions: AssistantAction[] = [];
const toolCalls = firstMessage.tool_calls ?? [];
if (toolCalls.length === 0) {
return {
message: {
role: 'assistant',
content: this.normalizeAssistantContent(firstMessage.content),
},
actions,
};
}
const toolMessages: MistralMessage[] = [];
for (const toolCall of toolCalls) {
const toolResult = await this.executeToolCall(userId, toolCall, actions);
toolMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(toolResult),
});
}
const finalResponse = await this.callMistral([
this.systemMessage(),
...messages.map((message) => ({
role: message.role,
content: message.content,
})),
{
role: 'assistant',
content: firstMessage.content ?? '',
tool_calls: toolCalls,
},
...toolMessages,
]);
const finalMessage = this.extractMessage(finalResponse);
return {
message: {
role: 'assistant',
content: this.normalizeAssistantContent(finalMessage.content),
},
actions,
};
}
private async executeToolCall(
userId: string,
toolCall: MistralToolCall,
actions: AssistantAction[],
): Promise<object> {
const args = this.parseToolArguments(toolCall.function.arguments);
if (toolCall.function.name === 'list_existing_lists') {
return {
lists: (await this.listsService.listLists(userId)).map((list) => ({
id: list.id,
name: list.name,
description: list.description,
kind: list.kind,
itemCount: list.items.length,
items: list.items.map((item) => ({
id: item.id,
title: item.title,
required: item.required,
checked: item.checked,
})),
})),
};
}
if (toolCall.function.name === 'create_list') {
const input = this.normalizeCreateListInput(args);
let list = await this.listsService.createList(userId, {
name: input.name!,
description: input.description,
kind: input.kind,
});
for (const item of input.items ?? []) {
list = await this.listsService.addItem(userId, list.id, item);
}
actions.push({
type: 'list.created',
listId: list.id,
list,
});
return { list };
}
if (toolCall.function.name === 'add_list_item') {
const input = this.normalizeAddListItemInput(args);
const list = await this.listsService.addItem(userId, input.listId!, {
title: input.title!,
notes: input.notes,
quantity: input.quantity,
required: input.required,
});
actions.push({
type: 'list.item_added',
listId: list.id,
itemTitle: input.title!,
list,
});
return { list };
}
return { error: `Unsupported tool: ${toolCall.function.name}` };
}
private async callMistral(messages: MistralMessage[]) {
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey) {
throw new ServiceUnavailableException('Mistral API key is not configured.');
}
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: process.env.MISTRAL_MODEL ?? 'mistral-small-latest',
messages,
tools: this.tools(),
tool_choice: 'auto',
}),
});
if (!response.ok) {
throw new ServiceUnavailableException('Mistral API request failed.');
}
return (await response.json()) as MistralChatResponse;
}
private normalizeMessages(
messages: AssistantChatMessage[] | undefined,
): AssistantChatMessage[] {
if (!Array.isArray(messages) || messages.length === 0) {
throw new BadRequestException('At least one chat message is required.');
}
return messages.slice(-12).map((message) => {
if (message.role !== 'user' && message.role !== 'assistant') {
throw new BadRequestException('Chat message role is invalid.');
}
const content = message.content?.trim();
if (!content) {
throw new BadRequestException('Chat message content is required.');
}
return { role: message.role, content };
});
}
private normalizeCreateListInput(value: unknown): CreateListToolInput {
const input = this.objectValue(value);
const name = this.requiredString(input.name, 'name');
const description = this.optionalString(input.description);
const kind = this.optionalKind(input.kind);
const rawItems = input.items;
if (rawItems !== undefined && !Array.isArray(rawItems)) {
throw new BadRequestException('items must be an array.');
}
return {
name,
description,
kind,
items: rawItems
?.slice(0, 50)
.map((item) => this.normalizeAddListItemInput(item, false)),
};
}
private normalizeAddListItemInput(
value: unknown,
requireListId = true,
): AddListItemToolInput {
const input = this.objectValue(value);
const title = this.requiredString(input.title, 'title');
const listId = requireListId
? this.requiredString(input.listId, 'listId')
: undefined;
const notes = this.optionalString(input.notes);
const quantity =
input.quantity === undefined ? undefined : Number(input.quantity);
if (
quantity !== undefined &&
(!Number.isFinite(quantity) || quantity <= 0)
) {
throw new BadRequestException('quantity must be greater than zero.');
}
if (
input.required !== undefined &&
typeof input.required !== 'boolean'
) {
throw new BadRequestException('required must be a boolean.');
}
return {
listId,
title,
notes,
quantity,
required: input.required,
};
}
private parseToolArguments(value: string): unknown {
try {
return value ? (JSON.parse(value) as unknown) : {};
} catch {
throw new BadRequestException('Tool arguments must be valid JSON.');
}
}
private objectValue(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new BadRequestException('Tool arguments must be an object.');
}
return value as Record<string, unknown>;
}
private requiredString(value: unknown, field: string): string {
const normalized = typeof value === 'string' ? value.trim() : '';
if (!normalized) {
throw new BadRequestException(`${field} is required.`);
}
return normalized;
}
private optionalString(value: unknown): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
const normalized = typeof value === 'string' ? value.trim() : '';
return normalized || undefined;
}
private optionalKind(value: unknown): ListTemplateKind | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (
value === 'packing' ||
value === 'shopping' ||
value === 'todo' ||
value === 'custom'
) {
return value;
}
throw new BadRequestException('kind is invalid.');
}
private extractMessage(response: MistralChatResponse) {
const message = response.choices?.[0]?.message;
if (!message) {
throw new ServiceUnavailableException('Mistral response was empty.');
}
return message;
}
private normalizeAssistantContent(content: string | null | undefined): string {
return content?.trim() || 'Ich habe die Anfrage verarbeitet.';
}
private systemMessage(): MistralMessage {
return {
role: 'system',
content:
'Du bist der Listify-Assistent. Antworte knapp und hilfreich auf Deutsch. ' +
'Nutze Tools, wenn der Nutzer Listen sehen, neue Listen erstellen oder Items hinzufuegen moechte. ' +
'Erstelle oder aendere nur, wenn der Nutzer das klar anfordert. Loeschen, Teilen, Abhaken und Bearbeiten bestehender Items ist nicht erlaubt.',
};
}
private tools() {
return [
{
type: 'function',
function: {
name: 'list_existing_lists',
description: 'Liest die Listen des angemeldeten Users.',
parameters: {
type: 'object',
properties: {},
},
},
},
{
type: 'function',
function: {
name: 'create_list',
description:
'Erstellt eine neue Liste mit optionalen Start-Items fuer den angemeldeten User.',
parameters: {
type: 'object',
required: ['name'],
properties: {
name: { type: 'string' },
description: { type: 'string' },
kind: {
type: 'string',
enum: ['packing', 'shopping', 'todo', 'custom'],
},
items: {
type: 'array',
items: {
type: 'object',
required: ['title'],
properties: {
title: { type: 'string' },
notes: { type: 'string' },
quantity: { type: 'number' },
required: { type: 'boolean' },
},
},
},
},
},
},
},
{
type: 'function',
function: {
name: 'add_list_item',
description:
'Fuegt ein Item zu einer bestehenden Liste hinzu, auf die der User Zugriff hat.',
parameters: {
type: 'object',
required: ['listId', 'title'],
properties: {
listId: { type: 'string' },
title: { type: 'string' },
notes: { type: 'string' },
quantity: { type: 'number' },
required: { type: 'boolean' },
},
},
},
},
];
}
}

View File

@@ -0,0 +1,45 @@
import { ListTemplateKind, UserList } from '../list-templates/list-template.types';
export type AssistantMessageRole = 'user' | 'assistant';
export interface AssistantChatMessage {
role: AssistantMessageRole;
content: string;
}
export interface AssistantChatRequest {
messages?: AssistantChatMessage[];
}
export type AssistantAction =
| {
type: 'list.created';
listId: string;
list: UserList;
}
| {
type: 'list.item_added';
listId: string;
itemTitle: string;
list: UserList;
};
export interface AssistantChatResponse {
message: AssistantChatMessage;
actions: AssistantAction[];
}
export interface CreateListToolInput {
name?: string;
description?: string;
kind?: ListTemplateKind;
items?: AddListItemToolInput[];
}
export interface AddListItemToolInput {
listId?: string;
title?: string;
notes?: string;
quantity?: number;
required?: boolean;
}

View File

@@ -28,8 +28,8 @@ import { UserEntity } from './user.entity';
@Injectable()
export class AuthService {
private readonly accessTokenExpiresIn = '15m';
private readonly refreshTokenExpiresIn = '7d';
private readonly accessTokenExpiresIn = '7d';
private readonly refreshTokenExpiresIn = '30d';
private readonly accessTokenSecret =
process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret';
private readonly refreshTokenSecret =