This commit is contained in:
Bastian Wagner
2026-06-13 15:41:57 +02:00
parent 6642575ea9
commit 22c93f9ca1
10 changed files with 874 additions and 19 deletions

View File

@@ -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',
};
}

View File

@@ -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;

View File

@@ -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[];