list suggestions

This commit is contained in:
Bastian Wagner
2026-06-15 14:42:58 +02:00
parent b979a3b097
commit 3998923693
10 changed files with 204 additions and 100 deletions

View File

@@ -40,6 +40,17 @@ export class ListsController {
return this.listsService.createList(this.requireUserId(request), createDto);
}
@Post('with-item-suggestions')
createListWithItemSuggestions(
@Req() request: AuthenticatedRequest,
@Body() createDto: CreateListDto,
) {
return this.listsService.createListWithItemSuggestions(
this.requireUserId(request),
createDto,
);
}
@Get()
listLists(@Req() request: AuthenticatedRequest) {
return this.listsService.listLists(this.requireUserId(request));

View File

@@ -465,6 +465,55 @@ describe('ListsService', () => {
expect(requestPayload.tools).toBeUndefined();
});
it('creates a list and returns item suggestions for it', async () => {
process.env.MISTRAL_API_KEY = 'test-key';
process.env.MISTRAL_AGENT_ID = 'agent-listify';
mockMistralResponse({
choices: [
{
message: {
content: JSON.stringify({
suggestions: [
{
title: 'Location buchen',
notes: 'Mit Kapazitaet abgleichen',
required: true,
},
],
}),
},
},
],
});
const response = await service.createListWithItemSuggestions('user-1', {
name: 'Sommerfest',
description: 'Planung fuer Team-Event',
kind: 'todo',
});
expect(response.list.name).toBe('Sommerfest');
expect(response.list.items).toHaveLength(0);
expect(response.suggestions).toEqual([
{
title: 'Location buchen',
notes: 'Mit Kapazitaet abgleichen',
quantity: undefined,
required: true,
},
]);
await expect(service.listLists('user-1')).resolves.toHaveLength(1);
const requestPayload = getMistralRequestPayload();
expect(requestPayload.messages[1].content).toContain('Name: Sommerfest');
expect(requestPayload.messages[1].content).toContain(
'Beschreibung: Planung fuer Team-Event',
);
expect(requestPayload.messages[1].content).toContain(
'Vorhandene Items: keine',
);
});
it('returns an empty suggestion list for malformed provider content', async () => {
process.env.MISTRAL_API_KEY = 'test-key';
process.env.MISTRAL_AGENT_ID = 'agent-listify';

View File

@@ -41,6 +41,11 @@ export interface ListItemSuggestionsResponse {
suggestions: ListItemSuggestion[];
}
export interface CreateListWithItemSuggestionsResponse {
list: UserList;
suggestions: ListItemSuggestion[];
}
interface MistralAgentCompletionResponse {
choices?: Array<{
message?: {
@@ -109,6 +114,21 @@ export class ListsService {
return userList;
}
async createListWithItemSuggestions(
ownerId: string,
createDto: CreateListDto,
): Promise<CreateListWithItemSuggestionsResponse> {
const list = await this.createList(ownerId, createDto);
const listEntity = await this.findAccessibleList(ownerId, list.id);
const response = await this.callMistralForItemSuggestions(listEntity);
const suggestions = this.normalizeItemSuggestions(response, listEntity.items);
return {
list,
suggestions,
};
}
async createListFromTemplate(
ownerId: string,
template: ListTemplate,

View File

@@ -63,7 +63,9 @@
</button>
@if (canDeleteList()) {
<button mat-icon-button
<button
mat-stroked-button
class="delete-list-button"
type="button"
color="warn"
[disabled]="deletingList()"
@@ -71,8 +73,10 @@
>
@if (deletingList()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">delete</mat-icon>
}
<mat-icon aria-hidden="true">delete</mat-icon>
Loeschen
</button>
}
</div>
@@ -95,14 +99,32 @@
<textarea matInput formControlName="description" rows="4"></textarea>
</mat-form-field>
<button mat-flat-button type="submit" [disabled]="saving()">
@if (saving()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">save</mat-icon>
<div class="list-form-actions" [class.create-actions]="isCreateMode()">
<button mat-flat-button type="submit" [disabled]="saving() || creatingWithAi()">
@if (saving()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">save</mat-icon>
}
{{ isCreateMode() ? 'Liste anlegen' : 'Speichern' }}
</button>
@if (isCreateMode()) {
<button
mat-stroked-button
type="button"
[disabled]="saving() || creatingWithAi() || !onlineStatus.online()"
(click)="createListWithAi()"
>
@if (creatingWithAi()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">auto_awesome</mat-icon>
}
Liste mit KI erstellen
</button>
}
{{ isCreateMode() ? 'Liste anlegen' : 'Speichern' }}
</button>
</div>
</form>
} @else {
<div class="list-summary">

View File

@@ -2,11 +2,9 @@
display: grid;
gap: 1rem;
width: min(100%, 760px);
max-width: 100%;
min-height: calc(100dvh - 56px);
margin: 0 auto;
padding: 1rem;
overflow-x: hidden;
}
.detail-header {
@@ -14,7 +12,6 @@
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.75rem;
min-width: 0;
padding: 0.75rem 0 0.25rem;
}
@@ -37,7 +34,6 @@
.sharing-card,
.items-card {
min-width: 0;
max-width: 100%;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
border-radius: 8px;
@@ -51,8 +47,10 @@
gap: 0.75rem;
}
.share-search-field {
width: 100%;
.detail-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.share-results,
@@ -108,10 +106,6 @@
margin-right: 0.5rem;
}
.editor-card mat-card-header button {
flex: 0 0 auto;
}
.list-form,
.item-form {
display: grid;
@@ -126,7 +120,12 @@
width: 100%;
}
.list-form button[type='submit'],
.create-actions {
display: grid;
gap: 0.6rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.item-form button[type='submit'] {
min-height: 48px;
}
@@ -236,10 +235,6 @@
color: var(--mat-sys-on-surface-variant);
}
.secondary-back {
justify-self: start;
}
@media (min-width: 701px) {
.list-detail-page {
gap: 1.25rem;
@@ -256,3 +251,9 @@
align-items: center;
}
}
@media (max-width: 520px) {
.create-actions {
grid-template-columns: 1fr;
}
}

View File

@@ -64,6 +64,7 @@ export class ListDetailComponent implements OnInit {
protected readonly isCreateMode = signal(false);
protected readonly loading = signal(true);
protected readonly saving = signal(false);
protected readonly creatingWithAi = signal(false);
protected readonly creatingTemplate = signal(false);
protected readonly deletingList = signal(false);
protected readonly editing = signal(false);
@@ -163,11 +164,7 @@ export class ListDetailComponent implements OnInit {
return;
}
const formValue = this.listForm.getRawValue();
const payload = {
name: formValue.name.trim(),
description: formValue.description.trim() || undefined,
};
const payload = this.listFormPayload();
const saveRequest =
this.isCreateMode() || !listId
? this.listsService.createList(payload)
@@ -193,8 +190,43 @@ export class ListDetailComponent implements OnInit {
});
}
protected createListWithAi(): void {
if (!this.isCreateMode() || this.listForm.invalid || this.creatingWithAi()) {
this.listForm.markAllAsTouched();
return;
}
this.creatingWithAi.set(true);
this.suggestionsLoaded.set(false);
this.itemSuggestions.set([]);
this.listsService
.createListWithItemSuggestions(this.listFormPayload())
.pipe(finalize(() => this.creatingWithAi.set(false)))
.subscribe({
next: (response) => {
this.setList(response.list);
this.itemSuggestions.set(response.suggestions);
this.suggestionsLoaded.set(true);
this.isCreateMode.set(false);
this.editing.set(true);
this.snackBar.open('Liste mit KI erstellt.', 'OK', {
duration: 2500,
});
void this.router.navigate(['/lists', response.list.id], {
replaceUrl: true,
});
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
duration: 5000,
});
},
});
}
protected addItem(): void {
const listId = this.listId();
const listId = this.currentListId();
if (!listId || !this.canEditItems() || this.itemForm.invalid) {
this.itemForm.markAllAsTouched();
@@ -286,7 +318,7 @@ export class ListDetailComponent implements OnInit {
}
protected loadSuggestions(): void {
const listId = this.listId();
const listId = this.currentListId();
if (!listId || !this.canEditItems() || this.loadingSuggestions()) {
return;
@@ -310,7 +342,7 @@ export class ListDetailComponent implements OnInit {
}
protected addSuggestion(suggestion: ListItemSuggestion): void {
const listId = this.listId();
const listId = this.currentListId();
if (!listId || this.addingSuggestionTitle()) {
return;
@@ -343,7 +375,7 @@ export class ListDetailComponent implements OnInit {
}
protected toggleItem(item: UserListItem, checked: boolean): void {
const listId = this.listId();
const listId = this.currentListId();
const currentList = this.list();
if (!listId || !currentList || this.updatingItemId()) {
@@ -520,6 +552,19 @@ export class ListDetailComponent implements OnInit {
return this.route.snapshot.paramMap.get('listId');
}
private currentListId(): string | null {
return this.listId() ?? this.list()?.id ?? null;
}
private listFormPayload() {
const formValue = this.listForm.getRawValue();
return {
name: formValue.name.trim(),
description: formValue.description.trim() || undefined,
};
}
private uncheckedFirst(items: UserListItem[]): UserListItem[] {
return items
.map((item, index) => ({ item, index }))

View File

@@ -167,23 +167,6 @@
</mat-card-content>
<mat-card-actions align="end">
@if (list.accessRole === 'owner') {
<button
mat-button
type="button"
color="warn"
[disabled]="deletingListId() === list.id"
(click)="deleteList(list)"
>
@if (deletingListId() === list.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">delete</mat-icon>
}
Loeschen
</button>
}
<a
mat-button
[routerLink]="['/lists', list.id]"

View File

@@ -2,20 +2,16 @@ import { DatePipe } from '@angular/common';
import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCardModule } from '@angular/material/card';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
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 { MatSelectModule } from '@angular/material/select';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { getAuthErrorMessage } from '../auth/error-message';
import { OnboardingService } from '../onboarding/onboarding.service';
import { ConfirmDeleteListDialogComponent } from './confirm-delete-list-dialog/confirm-delete-list-dialog.component';
import { ListTemplateKind } from '../templates/templates.models';
import { ListRealtimeEvent, UserList, UserListItem } from './lists.models';
import { ListsRealtimeService } from './lists-realtime.service';
@@ -38,28 +34,23 @@ type ListKindFilter = ListTemplateKind | 'all';
MatButtonModule,
MatButtonToggleModule,
MatCardModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSelectModule,
MatSnackBarModule,
],
templateUrl: './lists.component.html',
styleUrls: ['../workspace-page.scss', './lists.component.scss'],
})
export class ListsComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly dialog = inject(MatDialog);
private readonly listsService = inject(ListsService);
private readonly listsRealtimeService = inject(ListsRealtimeService);
private readonly snackBar = inject(MatSnackBar);
protected readonly onboarding = inject(OnboardingService);
protected readonly lists = signal<UserList[]>([]);
protected readonly loading = signal(true);
protected readonly deletingListId = signal<string | null>(null);
protected readonly errorMessage = signal<string | null>(null);
protected readonly searchTerm = signal('');
protected readonly kindFilter = signal<ListKindFilter>('all');
@@ -180,47 +171,6 @@ export class ListsComponent implements OnInit {
this.sortOption.set('updated-desc');
}
protected deleteList(list: UserList): void {
if (list.accessRole !== 'owner' || this.deletingListId()) {
return;
}
this.dialog
.open<ConfirmDeleteListDialogComponent, { listName: string }, boolean>(
ConfirmDeleteListDialogComponent,
{
data: { listName: list.name },
maxWidth: '420px',
width: 'calc(100vw - 32px)',
},
)
.afterClosed()
.subscribe((confirmed) => {
if (!confirmed) {
return;
}
this.deletingListId.set(list.id);
this.listsService
.deleteList(list.id)
.pipe(finalize(() => this.deletingListId.set(null)))
.subscribe({
next: () => {
this.lists.update((lists) =>
lists.filter((existingList) => existingList.id !== list.id),
);
this.snackBar.open('Liste geloescht.', 'OK', { duration: 3000 });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
duration: 5000,
});
},
});
});
}
private subscribeToRealtime(): void {
this.listsRealtimeService
.events()

View File

@@ -71,6 +71,11 @@ export interface ListItemSuggestionsResponse {
suggestions: ListItemSuggestion[];
}
export interface CreateListWithItemSuggestionsResponse {
list: UserList;
suggestions: ListItemSuggestion[];
}
export interface UpdateListItemRequest {
title?: string;
notes?: string;

View File

@@ -5,6 +5,7 @@ import { of, tap, throwError } from 'rxjs';
import {
AddListItemRequest,
CreateListRequest,
CreateListWithItemSuggestionsResponse,
ListItemSuggestionsResponse,
UpdateListItemRequest,
UpdateListRequest,
@@ -59,6 +60,23 @@ export class ListsService {
return this.http.post<UserList>(this.apiUrl, data);
}
createListWithItemSuggestions(
data: CreateListRequest,
): Observable<CreateListWithItemSuggestionsResponse> {
if (!this.onlineStatus.online()) {
return throwError(
() => new Error('Listen mit KI koennen nur online erstellt werden.'),
);
}
return this.http
.post<CreateListWithItemSuggestionsResponse>(
`${this.apiUrl}/with-item-suggestions`,
data,
)
.pipe(tap((response) => this.upsertCachedList(response.list)));
}
updateList(listId: string, data: UpdateListRequest): Observable<UserList> {
if (!this.onlineStatus.online()) {
const updatedList = this.updateCachedList(listId, (list) => ({