diff --git a/listify-api/src/assistant/assistant.controller.ts b/listify-api/src/assistant/assistant.controller.ts index 42ae86c..1553a45 100644 --- a/listify-api/src/assistant/assistant.controller.ts +++ b/listify-api/src/assistant/assistant.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Get, Post, Req, UnauthorizedException, @@ -16,6 +17,17 @@ import type { AssistantChatRequest } from './assistant.types'; export class AssistantController { constructor(private readonly assistantService: AssistantService) {} + @Get('chat/logs') + listChatLogs(@Req() request: AuthenticatedRequest) { + const userId = request.user?.sub; + + if (!userId) { + throw new UnauthorizedException('Authenticated user is required.'); + } + + return this.assistantService.listChatLogs(userId); + } + @Post('chat') chat( @Req() request: AuthenticatedRequest, diff --git a/listify-api/src/assistant/assistant.service.spec.ts b/listify-api/src/assistant/assistant.service.spec.ts index 1d48f03..1acb3be 100644 --- a/listify-api/src/assistant/assistant.service.spec.ts +++ b/listify-api/src/assistant/assistant.service.spec.ts @@ -12,6 +12,7 @@ describe('AssistantService', () => { const originalAgentId = process.env.MISTRAL_AGENT_ID; let chatLogsRepository: { create: jest.Mock; + find: jest.Mock; save: jest.Mock; }; let listRealtimeService: { @@ -29,6 +30,7 @@ describe('AssistantService', () => { global.fetch = jest.fn(); chatLogsRepository = { create: jest.fn((input) => input), + find: jest.fn(), save: jest.fn(async (input) => input), }; listRealtimeService = { @@ -605,6 +607,15 @@ describe('AssistantService', () => { messages: [{ role: 'user', content: 'Hallo' }], }), ).rejects.toThrow(ServiceUnavailableException); + expect(chatLogsRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + statusCode: null, + responsePayload: null, + assistantContent: null, + errorMessage: 'Mistral API key is not configured.', + }), + ); }); it('fails clearly when the agent id is missing', async () => { @@ -615,14 +626,62 @@ describe('AssistantService', () => { messages: [{ role: 'user', content: 'Hallo' }], }), ).rejects.toThrow(ServiceUnavailableException); + expect(chatLogsRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + agentId: null, + statusCode: null, + responsePayload: null, + assistantContent: null, + errorMessage: 'Mistral agent id is not configured.', + }), + ); + }); + + it('lists the latest chat logs for the current user', async () => { + chatLogsRepository.find.mockResolvedValue([ + { + id: 'log-1', + userId: 'user-1', + provider: 'mistral', + endpoint: 'https://api.mistral.ai/v1/agents/completions', + agentId: 'agent-listify', + statusCode: 502, + durationMs: 123, + requestPayload: { messages: [] }, + responsePayload: { message: 'connector failed' }, + assistantContent: null, + errorMessage: 'Mistral agent request failed.', + createdAt: new Date('2026-06-24T08:00:00.000Z'), + }, + ]); + + const logs = await service.listChatLogs('user-1'); + + expect(chatLogsRepository.find).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + order: { createdAt: 'DESC' }, + take: 50, + }); + expect(logs).toEqual([ + { + id: 'log-1', + provider: 'mistral', + endpoint: 'https://api.mistral.ai/v1/agents/completions', + agentId: 'agent-listify', + statusCode: 502, + durationMs: 123, + requestPayload: { messages: [] }, + responsePayload: { message: 'connector failed' }, + assistantContent: null, + errorMessage: 'Mistral agent request failed.', + createdAt: '2026-06-24T08:00:00.000Z', + }, + ]); }); }); -function mockMistralResponse( - response: object, - ok = true, - status = 200, -): void { +function mockMistralResponse(response: object, ok = true, status = 200): void { jest.mocked(global.fetch).mockResolvedValue({ ok, status, diff --git a/listify-api/src/assistant/assistant.service.ts b/listify-api/src/assistant/assistant.service.ts index 34430b3..498ddb2 100644 --- a/listify-api/src/assistant/assistant.service.ts +++ b/listify-api/src/assistant/assistant.service.ts @@ -17,12 +17,15 @@ import { AssistantChatLogEntity } from './assistant-chat-log.entity'; import { AssistantAction, AssistantChatMessage, + AssistantChatLog, AssistantPageContext, AssistantChatRequest, AssistantChatResponse, } from './assistant.types'; -type MistralMessage = AssistantChatMessage | { role: 'system'; content: string }; +type MistralMessage = + | AssistantChatMessage + | { role: 'system'; content: string }; interface NormalizedContextItem { id: string; @@ -122,7 +125,8 @@ export class AssistantService { const response = await this.callMistralAgent(userId, messages, context); const actions = this.extractActions(response); const content = - this.extractAssistantContent(response) ?? this.createActionContent(actions); + this.extractAssistantContent(response) ?? + this.createActionContent(actions); const latestUserMessage = this.latestUserMessage(messages); actions.forEach((action) => { @@ -144,6 +148,21 @@ export class AssistantService { } if (!content) { + await this.recordChatLog({ + userId, + provider: 'listify', + endpoint: 'assistant:emptyResponse', + agentId: null, + requestPayload: { + latestUserMessage: latestUserMessage?.content, + actionCount: actions.length, + }, + responsePayload: { actions }, + statusCode: null, + durationMs: 0, + assistantContent: null, + errorMessage: 'Mistral response was empty.', + }); throw new ServiceUnavailableException('Mistral response was empty.'); } @@ -156,6 +175,28 @@ export class AssistantService { }; } + async listChatLogs(userId: string): Promise { + const logs = await this.chatLogsRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + take: 50, + }); + + return logs.slice(0, 50).map((log) => ({ + id: log.id, + provider: log.provider, + endpoint: log.endpoint, + agentId: log.agentId, + statusCode: log.statusCode, + durationMs: log.durationMs, + requestPayload: log.requestPayload, + responsePayload: log.responsePayload, + assistantContent: log.assistantContent, + errorMessage: log.errorMessage, + createdAt: log.createdAt.toISOString(), + })); + } + private async tryHandleLocalListQuery( userId: string, messages: AssistantChatMessage[], @@ -173,7 +214,10 @@ export class AssistantService { const visibleLists = wantsOpenLists ? lists.filter((list) => !this.isCompletedList(list)) : lists; - const assistantContent = this.formatListsAnswer(visibleLists, wantsOpenLists); + const assistantContent = this.formatListsAnswer( + visibleLists, + wantsOpenLists, + ); await this.recordChatLog({ userId, @@ -210,7 +254,9 @@ export class AssistantService { } const latestUserMessage = this.latestUserMessage(messages); - const itemTitle = this.extractItemTitleToAdd(latestUserMessage?.content ?? ''); + const itemTitle = this.extractItemTitleToAdd( + latestUserMessage?.content ?? '', + ); if (!itemTitle) { return null; @@ -384,15 +430,6 @@ export class AssistantService { ): Promise { const apiKey = process.env.MISTRAL_API_KEY; const agentId = process.env.MISTRAL_AGENT_ID; - - if (!apiKey) { - throw new ServiceUnavailableException('Mistral API key is not configured.'); - } - - if (!agentId) { - throw new ServiceUnavailableException('Mistral agent id is not configured.'); - } - const contextMessage = this.createContextSystemMessage(context); const requestPayload = { agent_id: agentId, @@ -419,6 +456,39 @@ export class AssistantService { type: 'text', }, }; + + if (!apiKey) { + await this.recordChatLog({ + userId, + agentId: agentId ?? null, + requestPayload, + responsePayload: null, + statusCode: null, + durationMs: 0, + assistantContent: null, + errorMessage: 'Mistral API key is not configured.', + }); + throw new ServiceUnavailableException( + 'Mistral API key is not configured.', + ); + } + + if (!agentId) { + await this.recordChatLog({ + userId, + agentId: null, + requestPayload, + responsePayload: null, + statusCode: null, + durationMs: 0, + assistantContent: null, + errorMessage: 'Mistral agent id is not configured.', + }); + throw new ServiceUnavailableException( + 'Mistral agent id is not configured.', + ); + } + const startedAt = Date.now(); let statusCode: number | null = null; let responsePayload: unknown = null; @@ -658,13 +728,17 @@ export class AssistantService { } private createActionContent(actions: AssistantAction[]): string | null { - const createdList = actions.find((action) => action.type === 'list.created'); + const createdList = actions.find( + (action) => action.type === 'list.created', + ); if (createdList) { return `Ich habe die Liste **${createdList.list.name}** angelegt.`; } - const addedItem = actions.find((action) => action.type === 'list.item_added'); + const addedItem = actions.find( + (action) => action.type === 'list.item_added', + ); if (addedItem) { return `Ich habe **${addedItem.itemTitle}** zu **${addedItem.list.name}** hinzugefuegt.`; diff --git a/listify-api/src/assistant/assistant.types.ts b/listify-api/src/assistant/assistant.types.ts index be03e08..da54cdf 100644 --- a/listify-api/src/assistant/assistant.types.ts +++ b/listify-api/src/assistant/assistant.types.ts @@ -41,6 +41,20 @@ export interface AssistantChatResponse { actions: AssistantAction[]; } +export interface AssistantChatLog { + id: string; + provider: string; + endpoint: string; + agentId?: string | null; + statusCode?: number | null; + durationMs?: number | null; + requestPayload: Record; + responsePayload?: unknown; + assistantContent?: string | null; + errorMessage?: string | null; + createdAt: string; +} + export interface CreateListToolInput { name?: string; description?: string; diff --git a/listify-api/src/database/migrations/1781700000000-CreateAssistantChatLogs.ts b/listify-api/src/database/migrations/1781700000000-CreateAssistantChatLogs.ts new file mode 100644 index 0000000..23132c9 --- /dev/null +++ b/listify-api/src/database/migrations/1781700000000-CreateAssistantChatLogs.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAssistantChatLogs1781700000000 implements MigrationInterface { + name = 'CreateAssistantChatLogs1781700000000'; + + public async up(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('assistant_chat_logs')) { + return; + } + + await queryRunner.query(` + CREATE TABLE \`assistant_chat_logs\` ( + \`id\` varchar(36) NOT NULL, + \`userId\` varchar(36) NOT NULL, + \`provider\` varchar(120) NOT NULL, + \`endpoint\` varchar(255) NOT NULL, + \`agentId\` varchar(255) NULL, + \`statusCode\` int NULL, + \`durationMs\` int NULL, + \`requestPayload\` json NOT NULL, + \`responsePayload\` json NULL, + \`assistantContent\` text NULL, + \`errorMessage\` text NULL, + \`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + INDEX \`IDX_assistant_chat_logs_user_id\` (\`userId\`), + INDEX \`IDX_assistant_chat_logs_created_at\` (\`createdAt\`), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB + `); + } + + public async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('assistant_chat_logs')) { + await queryRunner.query('DROP TABLE `assistant_chat_logs`'); + } + } +} diff --git a/listify-client/src/app/app.html b/listify-client/src/app/app.html index 123b1ba..d662399 100644 --- a/listify-client/src/app/app.html +++ b/listify-client/src/app/app.html @@ -11,7 +11,11 @@ } - + Listify @@ -33,12 +37,7 @@ {{ auth.user()?.name || auth.user()?.email }} } @else { - + Login @@ -95,6 +94,16 @@ Account + + + Assistant Logs + diff --git a/listify-client/src/app/app.routes.ts b/listify-client/src/app/app.routes.ts index 53e7fda..d799bb3 100644 --- a/listify-client/src/app/app.routes.ts +++ b/listify-client/src/app/app.routes.ts @@ -34,6 +34,14 @@ export const routes: Routes = [ { path: 'lists/new', component: ListDetailComponent, canActivate: [authGuard] }, { path: 'lists/:listId', component: ListDetailComponent, canActivate: [authGuard] }, { path: 'listen', redirectTo: 'lists' }, + { + path: 'assistant/logs', + loadComponent: () => + import('./assistant/assistant-logs.component').then( + (module) => module.AssistantLogsComponent, + ), + canActivate: [authGuard], + }, { path: 'account', component: AccountComponent, canActivate: [authGuard] }, { path: '**', redirectTo: 'login' }, ]; diff --git a/listify-client/src/app/assistant/assistant-logs.component.html b/listify-client/src/app/assistant/assistant-logs.component.html new file mode 100644 index 0000000..5a45b45 --- /dev/null +++ b/listify-client/src/app/assistant/assistant-logs.component.html @@ -0,0 +1,96 @@ +
+ + + @if (errorMessage()) { +

+ + {{ errorMessage() }} +

+ } + + @if (loading() && logs().length === 0) { +
+ + Logs werden geladen +
+ } @else if (logs().length === 0) { + + + +

Noch keine Chat-Logs vorhanden.

+
+
+ } @else { +
+ @for (log of logs(); track log.id) { + + + + {{ statusLabel(log) }} + {{ log.provider }} + + + {{ formatDate(log.createdAt) }} + @if (log.durationMs !== null && log.durationMs !== undefined) { + ยท {{ log.durationMs }} ms + } + + + + + + + @if (log.errorMessage) { +

+ + {{ log.errorMessage }} +

+ } + + @if (log.assistantContent) { +
+ Assistant +

{{ log.assistantContent }}

+
+ } + + + + + Request + +
{{ formatJson(log.requestPayload) }}
+
+ + + + Response + +
{{ formatJson(log.responsePayload) }}
+
+
+
+
+ } +
+ } +
diff --git a/listify-client/src/app/assistant/assistant-logs.component.scss b/listify-client/src/app/assistant/assistant-logs.component.scss new file mode 100644 index 0000000..43b9f49 --- /dev/null +++ b/listify-client/src/app/assistant/assistant-logs.component.scss @@ -0,0 +1,181 @@ +.assistant-logs-page { + display: grid; + gap: 1rem; + padding: 1rem; +} + +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.page-header h1, +.page-header p { + margin: 0; +} + +.page-header h1 { + font-size: 1.35rem; + font-weight: 650; + line-height: 1.25; +} + +.page-header p { + margin-top: 0.2rem; + color: var(--mat-sys-on-surface-variant); + font-size: 0.9rem; +} + +.page-header button, +.loading-state, +.error-message, +.log-error { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.page-header mat-progress-spinner { + display: inline-flex; + margin-right: 0.5rem; +} + +.error-message, +.log-error { + margin: 0; + color: var(--mat-sys-error); +} + +.loading-state { + justify-content: center; + padding: 2rem; + color: var(--mat-sys-on-surface-variant); +} + +.empty-state { + border-radius: 8px; +} + +.empty-state mat-card-content { + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--mat-sys-on-surface-variant); +} + +.empty-state p { + margin: 0; +} + +.log-list { + display: grid; + gap: 0.85rem; +} + +.log-card { + border-radius: 8px; +} + +.log-card.failed { + border-color: color-mix(in srgb, var(--mat-sys-error) 52%, transparent); +} + +mat-card-title { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + font-size: 1rem; +} + +.status-pill { + display: inline-flex; + align-items: center; + min-height: 1.35rem; + padding: 0 0.45rem; + border-radius: 999px; + background: color-mix(in srgb, var(--mat-sys-primary) 14%, var(--mat-sys-surface)); + color: var(--mat-sys-primary); + font-size: 0.75rem; + font-weight: 650; +} + +.failed .status-pill { + background: color-mix(in srgb, var(--mat-sys-error) 14%, var(--mat-sys-surface)); + color: var(--mat-sys-error); +} + +.metadata-grid { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 0.45rem 0.75rem; + margin: 0.75rem 0; + color: var(--mat-sys-on-surface-variant); + font-size: 0.85rem; +} + +.metadata-grid code { + min-width: 0; + overflow-wrap: anywhere; + color: var(--mat-sys-on-surface); +} + +.assistant-content { + display: grid; + gap: 0.35rem; + margin: 0.75rem 0; +} + +.assistant-content span { + color: var(--mat-sys-on-surface-variant); + font-size: 0.8rem; + font-weight: 650; +} + +.assistant-content p { + margin: 0; + overflow-wrap: anywhere; + line-height: 1.45; +} + +mat-accordion { + display: grid; + gap: 0.5rem; + margin-top: 0.75rem; +} + +pre { + max-height: 420px; + margin: 0; + overflow: auto; + padding: 0.75rem; + border-radius: 8px; + background: color-mix(in srgb, var(--mat-sys-surface-container-high) 72%, var(--mat-sys-surface)); + color: var(--mat-sys-on-surface); + font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace; + font-size: 0.78rem; + line-height: 1.45; +} + +@media (min-width: 760px) { + .assistant-logs-page { + padding: 1.5rem; + } +} + +@media (max-width: 620px) { + .page-header { + align-items: stretch; + flex-direction: column; + } + + .page-header button { + justify-content: center; + } + + .metadata-grid { + grid-template-columns: 1fr; + } +} diff --git a/listify-client/src/app/assistant/assistant-logs.component.ts b/listify-client/src/app/assistant/assistant-logs.component.ts new file mode 100644 index 0000000..326e9dc --- /dev/null +++ b/listify-client/src/app/assistant/assistant-logs.component.ts @@ -0,0 +1,72 @@ +import { Component, inject, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { finalize } from 'rxjs'; +import { getAuthErrorMessage } from '../auth/error-message'; +import { AssistantChatLog } from './assistant.models'; +import { AssistantService } from './assistant.service'; + +@Component({ + selector: 'app-assistant-logs', + imports: [ + MatButtonModule, + MatCardModule, + MatExpansionModule, + MatIconModule, + MatProgressSpinnerModule, + ], + templateUrl: './assistant-logs.component.html', + styleUrl: './assistant-logs.component.scss', +}) +export class AssistantLogsComponent { + private readonly assistantService = inject(AssistantService); + + protected readonly logs = signal([]); + protected readonly loading = signal(false); + protected readonly errorMessage = signal(null); + + constructor() { + this.loadLogs(); + } + + protected loadLogs(): void { + this.loading.set(true); + this.errorMessage.set(null); + + this.assistantService + .listChatLogs() + .pipe(finalize(() => this.loading.set(false))) + .subscribe({ + next: (logs) => this.logs.set(logs), + error: (error: unknown) => { + this.errorMessage.set(getAuthErrorMessage(error)); + }, + }); + } + + protected statusLabel(log: AssistantChatLog): string { + if (log.errorMessage) { + return 'Fehler'; + } + + if (typeof log.statusCode === 'number') { + return `${log.statusCode}`; + } + + return 'Lokal'; + } + + protected formatDate(value: string): string { + return new Date(value).toLocaleString('de-DE', { + dateStyle: 'medium', + timeStyle: 'medium', + }); + } + + protected formatJson(value: unknown): string { + return JSON.stringify(value ?? null, null, 2); + } +} diff --git a/listify-client/src/app/assistant/assistant.models.ts b/listify-client/src/app/assistant/assistant.models.ts index 430b47f..10cf055 100644 --- a/listify-client/src/app/assistant/assistant.models.ts +++ b/listify-client/src/app/assistant/assistant.models.ts @@ -41,3 +41,17 @@ export interface AssistantChatResponse { export interface AssistantConversationMessage extends AssistantChatMessage { actions?: AssistantAction[]; } + +export interface AssistantChatLog { + id: string; + provider: string; + endpoint: string; + agentId?: string | null; + statusCode?: number | null; + durationMs?: number | null; + requestPayload: Record; + responsePayload?: unknown; + assistantContent?: string | null; + errorMessage?: string | null; + createdAt: string; +} diff --git a/listify-client/src/app/assistant/assistant.service.ts b/listify-client/src/app/assistant/assistant.service.ts index cc0755b..e844839 100644 --- a/listify-client/src/app/assistant/assistant.service.ts +++ b/listify-client/src/app/assistant/assistant.service.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; -import { AssistantChatRequest, AssistantChatResponse } from './assistant.models'; +import { AssistantChatLog, AssistantChatRequest, AssistantChatResponse } from './assistant.models'; @Injectable({ providedIn: 'root' }) export class AssistantService { @@ -11,4 +11,8 @@ export class AssistantService { chat(request: AssistantChatRequest): Observable { return this.http.post(`${this.apiUrl}/chat`, request); } + + listChatLogs(): Observable { + return this.http.get(`${this.apiUrl}/chat/logs`); + } }