chat
This commit is contained in:
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user