From c40cb030f67c58dd927848fd3349512380cebce1 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Fri, 12 Jun 2026 10:31:01 +0200 Subject: [PATCH] chat --- listify-api/.env.docker.example | 3 + listify-api/.env.example | 3 + listify-api/README.md | 11 + listify-api/src/app.module.ts | 2 + .../src/assistant/assistant.controller.ts | 32 ++ listify-api/src/assistant/assistant.module.ts | 12 + .../src/assistant/assistant.service.spec.ts | 220 +++++++++ .../src/assistant/assistant.service.ts | 429 ++++++++++++++++++ listify-api/src/assistant/assistant.types.ts | 45 ++ listify-api/src/auth/auth.service.ts | 4 +- listify-client/src/app/app.html | 10 +- listify-client/src/app/app.scss | 23 + listify-client/src/app/app.ts | 2 + .../assistant/assistant-chat.component.html | 66 +++ .../assistant/assistant-chat.component.scss | 132 ++++++ .../app/assistant/assistant-chat.component.ts | 106 +++++ .../src/app/assistant/assistant.models.ts | 34 ++ .../src/app/assistant/assistant.service.ts | 14 + 18 files changed, 1143 insertions(+), 5 deletions(-) create mode 100644 listify-api/src/assistant/assistant.controller.ts create mode 100644 listify-api/src/assistant/assistant.module.ts create mode 100644 listify-api/src/assistant/assistant.service.spec.ts create mode 100644 listify-api/src/assistant/assistant.service.ts create mode 100644 listify-api/src/assistant/assistant.types.ts create mode 100644 listify-client/src/app/assistant/assistant-chat.component.html create mode 100644 listify-client/src/app/assistant/assistant-chat.component.scss create mode 100644 listify-client/src/app/assistant/assistant-chat.component.ts create mode 100644 listify-client/src/app/assistant/assistant.models.ts create mode 100644 listify-client/src/app/assistant/assistant.service.ts diff --git a/listify-api/.env.docker.example b/listify-api/.env.docker.example index a85eeb1..b53b6e5 100644 --- a/listify-api/.env.docker.example +++ b/listify-api/.env.docker.example @@ -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 diff --git a/listify-api/.env.example b/listify-api/.env.example index ea4bc28..914d47d 100644 --- a/listify-api/.env.example +++ b/listify-api/.env.example @@ -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 diff --git a/listify-api/README.md b/listify-api/README.md index ff3238b..5191bc0 100644 --- a/listify-api/README.md +++ b/listify-api/README.md @@ -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 diff --git a/listify-api/src/app.module.ts b/listify-api/src/app.module.ts index 6688fc8..abf782b 100644 --- a/listify-api/src/app.module.ts +++ b/listify-api/src/app.module.ts @@ -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, diff --git a/listify-api/src/assistant/assistant.controller.ts b/listify-api/src/assistant/assistant.controller.ts new file mode 100644 index 0000000..42ae86c --- /dev/null +++ b/listify-api/src/assistant/assistant.controller.ts @@ -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); + } +} diff --git a/listify-api/src/assistant/assistant.module.ts b/listify-api/src/assistant/assistant.module.ts new file mode 100644 index 0000000..e3f37d0 --- /dev/null +++ b/listify-api/src/assistant/assistant.module.ts @@ -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 {} diff --git a/listify-api/src/assistant/assistant.service.spec.ts b/listify-api/src/assistant/assistant.service.spec.ts new file mode 100644 index 0000000..586ff7e --- /dev/null +++ b/listify-api/src/assistant/assistant.service.spec.ts @@ -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; + 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(); +} diff --git a/listify-api/src/assistant/assistant.service.ts b/listify-api/src/assistant/assistant.service.ts new file mode 100644 index 0000000..74e0536 --- /dev/null +++ b/listify-api/src/assistant/assistant.service.ts @@ -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 { + 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 { + 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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new BadRequestException('Tool arguments must be an object.'); + } + + return value as Record; + } + + 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' }, + }, + }, + }, + }, + ]; + } +} diff --git a/listify-api/src/assistant/assistant.types.ts b/listify-api/src/assistant/assistant.types.ts new file mode 100644 index 0000000..0e56cea --- /dev/null +++ b/listify-api/src/assistant/assistant.types.ts @@ -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; +} diff --git a/listify-api/src/auth/auth.service.ts b/listify-api/src/auth/auth.service.ts index c876786..78aacdd 100644 --- a/listify-api/src/auth/auth.service.ts +++ b/listify-api/src/auth/auth.service.ts @@ -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 = diff --git a/listify-client/src/app/app.html b/listify-client/src/app/app.html index 6d04f19..cb6935e 100644 --- a/listify-client/src/app/app.html +++ b/listify-client/src/app/app.html @@ -88,9 +88,13 @@ -
- -
+
+
+ +
+ + +
diff --git a/listify-client/src/app/app.scss b/listify-client/src/app/app.scss index abe132c..63f3482 100644 --- a/listify-client/src/app/app.scss +++ b/listify-client/src/app/app.scss @@ -103,6 +103,15 @@ min-height: calc(100dvh - 56px); } +.shell-workspace { + display: grid; + min-height: calc(100dvh - 56px); +} + +.assistant-sidebar { + min-width: 0; +} + .shell .app-main { padding-bottom: calc(76px + env(safe-area-inset-bottom)); } @@ -189,6 +198,7 @@ .shell, .shell-content, + .shell-workspace, .app-main { min-height: calc(100dvh - 64px); } @@ -205,3 +215,16 @@ display: none; } } + +@media (min-width: 1041px) { + .shell-workspace { + grid-template-columns: minmax(0, 1fr) 360px; + align-items: start; + } + + .assistant-sidebar { + position: sticky; + top: 64px; + min-height: calc(100dvh - 64px); + } +} diff --git a/listify-client/src/app/app.ts b/listify-client/src/app/app.ts index ea80072..49e1837 100644 --- a/listify-client/src/app/app.ts +++ b/listify-client/src/app/app.ts @@ -9,6 +9,7 @@ import { MatSidenavModule } from '@angular/material/sidenav'; import { MatToolbarModule } from '@angular/material/toolbar'; import { map } from 'rxjs'; import { AuthService } from './auth/auth.service'; +import { AssistantChatComponent } from './assistant/assistant-chat.component'; import { OnboardingOverlayComponent } from './onboarding/onboarding-overlay.component'; @Component({ @@ -22,6 +23,7 @@ import { OnboardingOverlayComponent } from './onboarding/onboarding-overlay.comp MatListModule, MatSidenavModule, MatToolbarModule, + AssistantChatComponent, OnboardingOverlayComponent, ], templateUrl: './app.html', diff --git a/listify-client/src/app/assistant/assistant-chat.component.html b/listify-client/src/app/assistant/assistant-chat.component.html new file mode 100644 index 0000000..a6a407e --- /dev/null +++ b/listify-client/src/app/assistant/assistant-chat.component.html @@ -0,0 +1,66 @@ +
+
+
+

Assistent

+

Listen planen und erstellen

+
+ +
+ +
+ @for (message of messages(); track $index) { + + } + + @if (sending()) { +
+ + Antwort wird erstellt +
+ } +
+ + @if (errorMessage()) { +

+ + {{ errorMessage() }} +

+ } + +
+ + Nachricht + + + + +
+
diff --git a/listify-client/src/app/assistant/assistant-chat.component.scss b/listify-client/src/app/assistant/assistant-chat.component.scss new file mode 100644 index 0000000..90eca07 --- /dev/null +++ b/listify-client/src/app/assistant/assistant-chat.component.scss @@ -0,0 +1,132 @@ +:host { + display: block; + min-width: 0; +} + +.assistant-shell { + display: grid; + grid-template-rows: auto minmax(220px, 1fr) auto auto; + gap: 0.75rem; + width: 100%; + min-height: 420px; + max-height: calc(100dvh - 88px); + padding: 0.85rem; + border-left: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); + background: color-mix(in srgb, var(--mat-sys-surface) 94%, transparent); +} + +.assistant-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + min-width: 0; +} + +.assistant-header h2, +.assistant-header p { + margin: 0; +} + +.assistant-header h2 { + font-size: 1rem; + font-weight: 600; +} + +.assistant-header p { + margin-top: 0.15rem; + color: var(--mat-sys-on-surface-variant); + font-size: 0.8rem; +} + +.assistant-header mat-icon { + flex: 0 0 auto; + color: var(--mat-sys-primary); +} + +.message-list { + display: flex; + flex-direction: column; + gap: 0.65rem; + min-height: 0; + overflow-y: auto; + padding-right: 0.15rem; +} + +.message { + align-self: flex-start; + max-width: 92%; + padding: 0.7rem 0.8rem; + border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 70%, transparent); + border-radius: 8px; + background: var(--mat-sys-surface-container-low); +} + +.user-message { + align-self: flex-end; + background: color-mix(in srgb, var(--mat-sys-primary) 12%, var(--mat-sys-surface)); + color: var(--mat-sys-on-surface); +} + +.message p { + margin: 0; + overflow-wrap: anywhere; + line-height: 1.4; +} + +.action-list { + display: grid; + gap: 0.4rem; + margin-top: 0.65rem; +} + +.action-list a { + justify-content: start; + min-width: 0; + max-width: 100%; +} + +.assistant-loading, +.assistant-error { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--mat-sys-on-surface-variant); + font-size: 0.85rem; +} + +.assistant-error { + margin: 0; + color: var(--mat-sys-error); +} + +.assistant-error mat-icon { + width: 18px; + height: 18px; + font-size: 18px; +} + +.composer { + display: grid; + gap: 0.55rem; +} + +.composer mat-form-field { + width: 100%; +} + +.composer textarea { + resize: none; +} + +.composer button { + justify-self: end; +} + +@media (max-width: 1040px) { + .assistant-shell { + max-height: none; + border-top: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); + border-left: 0; + } +} diff --git a/listify-client/src/app/assistant/assistant-chat.component.ts b/listify-client/src/app/assistant/assistant-chat.component.ts new file mode 100644 index 0000000..2dfd9fe --- /dev/null +++ b/listify-client/src/app/assistant/assistant-chat.component.ts @@ -0,0 +1,106 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { finalize } from 'rxjs'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { getAuthErrorMessage } from '../auth/error-message'; +import { + AssistantAction, + AssistantChatMessage, + AssistantConversationMessage, +} from './assistant.models'; +import { AssistantService } from './assistant.service'; + +@Component({ + selector: 'app-assistant-chat', + imports: [ + RouterLink, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + ], + templateUrl: './assistant-chat.component.html', + styleUrl: './assistant-chat.component.scss', +}) +export class AssistantChatComponent { + private readonly assistantService = inject(AssistantService); + + protected readonly messages = signal([ + { + role: 'assistant', + content: 'Hallo, ich bin dein Listify-Assistent. Was soll ich vorbereiten?', + }, + ]); + protected readonly draft = signal(''); + protected readonly sending = signal(false); + protected readonly errorMessage = signal(null); + protected readonly canSend = computed( + () => this.draft().trim().length > 0 && !this.sending(), + ); + + protected send(): void { + const content = this.draft().trim(); + + if (!content || this.sending()) { + return; + } + + const userMessage: AssistantConversationMessage = { + role: 'user', + content, + }; + const nextMessages = [...this.messages(), userMessage]; + + this.messages.set(nextMessages); + this.draft.set(''); + 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))) + .subscribe({ + next: (response) => { + this.messages.update((messages) => [ + ...messages, + { + ...response.message, + actions: response.actions, + }, + ]); + }, + error: (error: unknown) => { + this.errorMessage.set(getAuthErrorMessage(error)); + }, + }); + } + + protected handleEnter(event: Event): void { + const keyboardEvent = event as KeyboardEvent; + + if (keyboardEvent.shiftKey) { + return; + } + + keyboardEvent.preventDefault(); + this.send(); + } + + protected actionLabel(action: AssistantAction): string { + if (action.type === 'list.created') { + return `Liste erstellt: ${action.list.name}`; + } + + return `Item hinzugefuegt: ${action.itemTitle}`; + } +} diff --git a/listify-client/src/app/assistant/assistant.models.ts b/listify-client/src/app/assistant/assistant.models.ts new file mode 100644 index 0000000..09e0c47 --- /dev/null +++ b/listify-client/src/app/assistant/assistant.models.ts @@ -0,0 +1,34 @@ +import { UserList } from '../lists/lists.models'; + +export type AssistantMessageRole = 'user' | 'assistant'; + +export interface AssistantChatMessage { + role: AssistantMessageRole; + content: string; +} + +export type AssistantAction = + | { + type: 'list.created'; + listId: string; + list: UserList; + } + | { + type: 'list.item_added'; + listId: string; + itemTitle: string; + list: UserList; + }; + +export interface AssistantChatRequest { + messages: AssistantChatMessage[]; +} + +export interface AssistantChatResponse { + message: AssistantChatMessage; + actions: AssistantAction[]; +} + +export interface AssistantConversationMessage extends AssistantChatMessage { + actions?: AssistantAction[]; +} diff --git a/listify-client/src/app/assistant/assistant.service.ts b/listify-client/src/app/assistant/assistant.service.ts new file mode 100644 index 0000000..cc0755b --- /dev/null +++ b/listify-client/src/app/assistant/assistant.service.ts @@ -0,0 +1,14 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { AssistantChatRequest, AssistantChatResponse } from './assistant.models'; + +@Injectable({ providedIn: 'root' }) +export class AssistantService { + private readonly http = inject(HttpClient); + private readonly apiUrl = '/api/assistant'; + + chat(request: AssistantChatRequest): Observable { + return this.http.post(`${this.apiUrl}/chat`, request); + } +}