Vorschläge
This commit is contained in:
@@ -150,7 +150,7 @@ export class AssistantService {
|
|||||||
{
|
{
|
||||||
type: 'connector',
|
type: 'connector',
|
||||||
connector_id: 'listify',
|
connector_id: 'listify',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
stream: false,
|
stream: false,
|
||||||
response_format: {
|
response_format: {
|
||||||
|
|||||||
@@ -118,6 +118,14 @@ export class ListsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':listId/item-suggestions')
|
||||||
|
suggestItems(
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@Param('listId') listId: string,
|
||||||
|
) {
|
||||||
|
return this.listsService.suggestItems(this.requireUserId(request), listId);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':listId/items/:itemId')
|
@Patch(':listId/items/:itemId')
|
||||||
async updateItem(
|
async updateItem(
|
||||||
@Req() request: AuthenticatedRequest,
|
@Req() request: AuthenticatedRequest,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { UserEntity } from '../auth/user.entity';
|
import { UserEntity } from '../auth/user.entity';
|
||||||
import { ListTemplate } from '../list-templates/list-template.types';
|
import { ListTemplate } from '../list-templates/list-template.types';
|
||||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||||
@@ -9,6 +13,9 @@ import { UserListItemEntity } from './user-list-item.entity';
|
|||||||
import { UserListShareEntity } from './user-list-share.entity';
|
import { UserListShareEntity } from './user-list-share.entity';
|
||||||
|
|
||||||
describe('ListsService', () => {
|
describe('ListsService', () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
const originalApiKey = process.env.MISTRAL_API_KEY;
|
||||||
|
const originalAgentId = process.env.MISTRAL_AGENT_ID;
|
||||||
let service: ListsService;
|
let service: ListsService;
|
||||||
let usersRepository: InMemoryRepository<UserEntity>;
|
let usersRepository: InMemoryRepository<UserEntity>;
|
||||||
|
|
||||||
@@ -20,6 +27,13 @@ describe('ListsService', () => {
|
|||||||
new InMemoryRepository<UserListShareEntity>() as never,
|
new InMemoryRepository<UserListShareEntity>() as never,
|
||||||
usersRepository as never,
|
usersRepository as never,
|
||||||
);
|
);
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
restoreEnv('MISTRAL_API_KEY', originalApiKey);
|
||||||
|
restoreEnv('MISTRAL_AGENT_ID', originalAgentId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates and lists concrete lists for the owning user', async () => {
|
it('creates and lists concrete lists for the owning user', async () => {
|
||||||
@@ -252,4 +266,151 @@ describe('ListsService', () => {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('suggests normalized items and filters existing titles', async () => {
|
||||||
|
process.env.MISTRAL_API_KEY = 'test-key';
|
||||||
|
process.env.MISTRAL_AGENT_ID = 'agent-listify';
|
||||||
|
const list = await service.createList('user-1', {
|
||||||
|
name: 'Sommerurlaub',
|
||||||
|
description: 'Eine Woche am Meer',
|
||||||
|
kind: 'packing',
|
||||||
|
});
|
||||||
|
await service.addItem('user-1', list.id, {
|
||||||
|
title: 'Pass',
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
mockMistralResponse({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: JSON.stringify({
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
title: 'Pass',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Sonnencreme',
|
||||||
|
notes: 'Reisegroesse',
|
||||||
|
quantity: 1,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'sonnencreme',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Badehose',
|
||||||
|
quantity: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: ' ',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await service.suggestItems('user-1', list.id);
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
title: 'Sonnencreme',
|
||||||
|
notes: 'Reisegroesse',
|
||||||
|
quantity: 1,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Badehose',
|
||||||
|
quantity: undefined,
|
||||||
|
notes: undefined,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'https://api.mistral.ai/v1/agents/completions',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer test-key',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const requestPayload = getMistralRequestPayload();
|
||||||
|
expect(requestPayload.messages[1].content).toContain('Name: Sommerurlaub');
|
||||||
|
expect(requestPayload.messages[1].content).toContain(
|
||||||
|
'Beschreibung: Eine Woche am Meer',
|
||||||
|
);
|
||||||
|
expect(requestPayload.messages[1].content).toContain('Titel: Pass');
|
||||||
|
expect(requestPayload.tools).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
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';
|
||||||
|
const list = await service.createList('user-1', {
|
||||||
|
name: 'Einkauf',
|
||||||
|
kind: 'shopping',
|
||||||
|
});
|
||||||
|
mockMistralResponse({
|
||||||
|
choices: [{ message: { content: 'keine json antwort' } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.suggestItems('user-1', list.id)).resolves.toEqual({
|
||||||
|
suggestions: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails clearly when item suggestions are not configured', async () => {
|
||||||
|
delete process.env.MISTRAL_API_KEY;
|
||||||
|
process.env.MISTRAL_AGENT_ID = 'agent-listify';
|
||||||
|
const list = await service.createList('user-1', {
|
||||||
|
name: 'Einkauf',
|
||||||
|
kind: 'shopping',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.suggestItems('user-1', list.id)).rejects.toThrow(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function mockMistralResponse(response: object, ok = true, status = 200): void {
|
||||||
|
jest.mocked(global.fetch).mockResolvedValue({
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
text: async () => JSON.stringify(response),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMistralRequestPayload(): {
|
||||||
|
messages: Array<{ role: string; content: string }>;
|
||||||
|
tools?: unknown;
|
||||||
|
} {
|
||||||
|
const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? [];
|
||||||
|
const body = init?.body;
|
||||||
|
|
||||||
|
if (typeof body !== 'string') {
|
||||||
|
throw new Error('Expected Mistral request body to be JSON.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(body) as {
|
||||||
|
messages: Array<{ role: string; content: string }>;
|
||||||
|
tools?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEnv(key: string, value: string | undefined): void {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Optional,
|
Optional,
|
||||||
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
@@ -27,8 +28,34 @@ import { UserListEntity } from './user-list.entity';
|
|||||||
import { UserListItemEntity } from './user-list-item.entity';
|
import { UserListItemEntity } from './user-list-item.entity';
|
||||||
import { UserListShareEntity } from './user-list-share.entity';
|
import { UserListShareEntity } from './user-list-share.entity';
|
||||||
|
|
||||||
|
export interface ListItemSuggestion {
|
||||||
|
title: string;
|
||||||
|
notes?: string;
|
||||||
|
quantity?: number;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListItemSuggestionsResponse {
|
||||||
|
suggestions: ListItemSuggestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MistralAgentCompletionResponse {
|
||||||
|
choices?: Array<{
|
||||||
|
message?: {
|
||||||
|
content?: string | null;
|
||||||
|
};
|
||||||
|
messages?: Array<{
|
||||||
|
role?: string;
|
||||||
|
content?: string | null | unknown[];
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ListsService {
|
export class ListsService {
|
||||||
|
private readonly itemSuggestionsEndpoint =
|
||||||
|
'https://api.mistral.ai/v1/agents/completions';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(UserListEntity)
|
@InjectRepository(UserListEntity)
|
||||||
private readonly listsRepository: Repository<UserListEntity>,
|
private readonly listsRepository: Repository<UserListEntity>,
|
||||||
@@ -460,6 +487,17 @@ export class ListsService {
|
|||||||
return updatedList;
|
return updatedList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async suggestItems(
|
||||||
|
ownerId: string,
|
||||||
|
listId: string,
|
||||||
|
): Promise<ListItemSuggestionsResponse> {
|
||||||
|
const list = await this.findAccessibleList(ownerId, listId);
|
||||||
|
const response = await this.callMistralForItemSuggestions(list);
|
||||||
|
const suggestions = this.normalizeItemSuggestions(response, list.items);
|
||||||
|
|
||||||
|
return { suggestions };
|
||||||
|
}
|
||||||
|
|
||||||
private async findAccessibleList(
|
private async findAccessibleList(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
listId: string,
|
listId: string,
|
||||||
@@ -722,4 +760,247 @@ export class ListsService {
|
|||||||
private toIsoString(value?: Date): string {
|
private toIsoString(value?: Date): string {
|
||||||
return (value ?? new Date()).toISOString();
|
return (value ?? new Date()).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async callMistralForItemSuggestions(
|
||||||
|
list: UserListEntity,
|
||||||
|
): Promise<unknown> {
|
||||||
|
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 response = await fetch(this.itemSuggestionsEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent_id: agentId,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'Du erzeugst Listify-Item-Vorschlaege. Antworte nur mit JSON im Format {"suggestions":[{"title":"...","notes":"...","quantity":1,"required":true}]}. Keine Markdown-Ausgabe.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: this.createItemSuggestionPrompt(list),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
response_format: {
|
||||||
|
type: 'json_object',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const responsePayload = await this.readMistralResponsePayload(response);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ServiceUnavailableException('Mistral agent request failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return responsePayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createItemSuggestionPrompt(list: UserListEntity): string {
|
||||||
|
const lines = [
|
||||||
|
'Erzeuge bis zu 6 sinnvolle neue Items fuer diese Liste.',
|
||||||
|
'Schlage keine Items vor, die bereits vorhanden sind.',
|
||||||
|
`Name: ${this.compactText(list.name, 160)}`,
|
||||||
|
`Typ: ${list.kind}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const description = this.compactText(list.description ?? undefined, 240);
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
lines.push(`Beschreibung: ${description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.items.length === 0) {
|
||||||
|
lines.push('Vorhandene Items: keine');
|
||||||
|
} else {
|
||||||
|
lines.push('Vorhandene Items:');
|
||||||
|
|
||||||
|
for (const item of list.items.slice(0, 40)) {
|
||||||
|
const parts = [
|
||||||
|
`Titel: ${this.compactText(item.title, 160)}`,
|
||||||
|
item.notes ? `Notizen: ${this.compactText(item.notes, 220)}` : null,
|
||||||
|
typeof item.quantity === 'number' ? `Menge: ${item.quantity}` : null,
|
||||||
|
`Pflicht: ${item.required ? 'ja' : 'nein'}`,
|
||||||
|
`Erledigt: ${item.checked ? 'ja' : 'nein'}`,
|
||||||
|
].filter((part): part is string => part !== null);
|
||||||
|
|
||||||
|
lines.push(`- ${parts.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readMistralResponsePayload(response: Response): Promise<unknown> {
|
||||||
|
const rawBody = await response.text();
|
||||||
|
|
||||||
|
if (!rawBody) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawBody) as unknown;
|
||||||
|
} catch {
|
||||||
|
return { rawBody };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeItemSuggestions(
|
||||||
|
responsePayload: unknown,
|
||||||
|
existingItems: UserListItemEntity[],
|
||||||
|
): ListItemSuggestion[] {
|
||||||
|
const content = this.extractMistralContent(responsePayload);
|
||||||
|
const parsed = this.parseSuggestionsJson(content);
|
||||||
|
const existingTitles = new Set(
|
||||||
|
existingItems.map((item) => this.suggestionKey(item.title)),
|
||||||
|
);
|
||||||
|
const seenTitles = new Set<string>();
|
||||||
|
const suggestions: ListItemSuggestion[] = [];
|
||||||
|
|
||||||
|
for (const value of parsed) {
|
||||||
|
const suggestion = this.normalizeItemSuggestion(value);
|
||||||
|
|
||||||
|
if (!suggestion) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this.suggestionKey(suggestion.title);
|
||||||
|
|
||||||
|
if (existingTitles.has(key) || seenTitles.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenTitles.add(key);
|
||||||
|
suggestions.push(suggestion);
|
||||||
|
|
||||||
|
if (suggestions.length === 6) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractMistralContent(responsePayload: unknown): string | null {
|
||||||
|
if (!responsePayload || typeof responsePayload !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = responsePayload as MistralAgentCompletionResponse;
|
||||||
|
const directContent = response.choices?.[0]?.message?.content?.trim();
|
||||||
|
|
||||||
|
if (directContent) {
|
||||||
|
return directContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const choice of response.choices ?? []) {
|
||||||
|
const assistantMessages = (choice.messages ?? [])
|
||||||
|
.filter((message) => message.role === 'assistant')
|
||||||
|
.reverse();
|
||||||
|
|
||||||
|
for (const message of assistantMessages) {
|
||||||
|
const content =
|
||||||
|
typeof message.content === 'string' ? message.content.trim() : '';
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSuggestionsJson(content: string | null): unknown[] {
|
||||||
|
if (!content) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content) as unknown;
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
const suggestions = (parsed as { suggestions?: unknown }).suggestions;
|
||||||
|
return Array.isArray(suggestions) ? suggestions : [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeItemSuggestion(value: unknown): ListItemSuggestion | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as {
|
||||||
|
title?: unknown;
|
||||||
|
notes?: unknown;
|
||||||
|
quantity?: unknown;
|
||||||
|
required?: unknown;
|
||||||
|
};
|
||||||
|
const title =
|
||||||
|
typeof candidate.title === 'string'
|
||||||
|
? this.compactText(candidate.title, 220)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity =
|
||||||
|
typeof candidate.quantity === 'number' &&
|
||||||
|
Number.isFinite(candidate.quantity) &&
|
||||||
|
candidate.quantity > 0
|
||||||
|
? candidate.quantity
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
notes:
|
||||||
|
typeof candidate.notes === 'string'
|
||||||
|
? this.compactText(candidate.notes, 500)
|
||||||
|
: undefined,
|
||||||
|
quantity,
|
||||||
|
required:
|
||||||
|
typeof candidate.required === 'boolean' ? candidate.required : true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private suggestionKey(value: string): string {
|
||||||
|
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private compactText(value: string | undefined, maxLength: number): string | undefined {
|
||||||
|
const compacted = value?.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
if (!compacted) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compacted.length <= maxLength) {
|
||||||
|
return compacted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${compacted.slice(0, maxLength - 3)}...`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ describe('AssistantChatComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to route context when list context loading fails', async () => {
|
it('falls back to route context when list context loading fails', async () => {
|
||||||
listsService.getList.mockReturnValue(throwError(() => new Error('not found')));
|
listsService.getList.mockReturnValue(
|
||||||
|
throwError(() => new Error('not found')),
|
||||||
|
);
|
||||||
await router.navigateByUrl('/lists/list-1');
|
await router.navigateByUrl('/lists/list-1');
|
||||||
const fixture = TestBed.createComponent(AssistantChatComponent);
|
const fixture = TestBed.createComponent(AssistantChatComponent);
|
||||||
const component = fixture.componentInstance as unknown as {
|
const component = fixture.componentInstance as unknown as {
|
||||||
|
|||||||
@@ -214,6 +214,75 @@
|
|||||||
Hinzufügen
|
Hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
@if (canEditItems()) {
|
||||||
|
<section class="suggestions-panel" aria-label="Smart Suggestions">
|
||||||
|
<div class="suggestions-header">
|
||||||
|
<div>
|
||||||
|
<h3>Smart Suggestions</h3>
|
||||||
|
<p>Passende Items basierend auf Name, Beschreibung und Inhalt.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
[disabled]="loadingSuggestions()"
|
||||||
|
(click)="loadSuggestions()"
|
||||||
|
>
|
||||||
|
@if (loadingSuggestions()) {
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||||
|
} @else {
|
||||||
|
<mat-icon aria-hidden="true">auto_awesome</mat-icon>
|
||||||
|
}
|
||||||
|
Vorschlaege
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (itemSuggestions().length > 0) {
|
||||||
|
<ul class="suggestion-list">
|
||||||
|
@for (suggestion of itemSuggestions(); track suggestion.title) {
|
||||||
|
<li>
|
||||||
|
<div>
|
||||||
|
<strong>{{ suggestion.title }}</strong>
|
||||||
|
@if (suggestion.notes || suggestion.quantity || !suggestion.required) {
|
||||||
|
<span>
|
||||||
|
@if (suggestion.quantity) {
|
||||||
|
Menge: {{ suggestion.quantity }}
|
||||||
|
}
|
||||||
|
@if (suggestion.notes) {
|
||||||
|
{{ suggestion.quantity ? '- ' : '' }}{{ suggestion.notes }}
|
||||||
|
}
|
||||||
|
@if (!suggestion.required) {
|
||||||
|
{{ suggestion.quantity || suggestion.notes ? '- ' : '' }}Optional
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="suggestion.title + ' hinzufuegen'"
|
||||||
|
[disabled]="addingSuggestionTitle() === suggestion.title"
|
||||||
|
(click)="addSuggestion(suggestion)"
|
||||||
|
>
|
||||||
|
@if (addingSuggestionTitle() === suggestion.title) {
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||||
|
} @else {
|
||||||
|
<mat-icon aria-hidden="true">add</mat-icon>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else if (suggestionsLoaded()) {
|
||||||
|
<div class="inline-empty">
|
||||||
|
<mat-icon aria-hidden="true">tips_and_updates</mat-icon>
|
||||||
|
<span>Keine neuen Vorschlaege gefunden.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!canEditItems()) {
|
@if (!canEditItems()) {
|
||||||
@@ -223,7 +292,7 @@
|
|||||||
</div>
|
</div>
|
||||||
} @else if (list()?.items?.length) {
|
} @else if (list()?.items?.length) {
|
||||||
<ul class="check-items">
|
<ul class="check-items">
|
||||||
@for (item of list()!.items; track item.id) {
|
@for (item of visibleItems(list()!); track item.id) {
|
||||||
<li [class.checked]="item.checked">
|
<li [class.checked]="item.checked">
|
||||||
<mat-checkbox
|
<mat-checkbox
|
||||||
[checked]="item.checked"
|
[checked]="item.checked"
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.share-results,
|
.share-results,
|
||||||
.collaborator-list {
|
.collaborator-list,
|
||||||
|
.suggestion-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin: 0.75rem 0 0;
|
margin: 0.75rem 0 0;
|
||||||
@@ -65,7 +66,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.share-results li,
|
.share-results li,
|
||||||
.collaborator-list li {
|
.collaborator-list li,
|
||||||
|
.suggestion-list li {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -79,24 +81,29 @@
|
|||||||
|
|
||||||
.share-results span,
|
.share-results span,
|
||||||
.collaborator-list strong,
|
.collaborator-list strong,
|
||||||
.collaborator-list span {
|
.collaborator-list span,
|
||||||
|
.suggestion-list strong,
|
||||||
|
.suggestion-list span {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collaborator-list div {
|
.collaborator-list div,
|
||||||
|
.suggestion-list div {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collaborator-list span {
|
.collaborator-list span,
|
||||||
|
.suggestion-list span {
|
||||||
color: var(--mat-sys-on-surface-variant);
|
color: var(--mat-sys-on-surface-variant);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-results mat-progress-spinner,
|
.share-results mat-progress-spinner,
|
||||||
.collaborator-list mat-progress-spinner {
|
.collaborator-list mat-progress-spinner,
|
||||||
|
.suggestion-list mat-progress-spinner {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -130,6 +137,23 @@
|
|||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.suggestions-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.state-card mat-card-content {
|
.state-card mat-card-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import { AuthService } from '../../auth/auth.service';
|
|||||||
import { getAuthErrorMessage } from '../../auth/error-message';
|
import { getAuthErrorMessage } from '../../auth/error-message';
|
||||||
import { PublicUserSearchResult } from '../../auth/auth.models';
|
import { PublicUserSearchResult } from '../../auth/auth.models';
|
||||||
import { OnboardingService } from '../../onboarding/onboarding.service';
|
import { OnboardingService } from '../../onboarding/onboarding.service';
|
||||||
import { ListRealtimeEvent, UserList, UserListItem } from '../lists.models';
|
import {
|
||||||
|
ListItemSuggestion,
|
||||||
|
ListRealtimeEvent,
|
||||||
|
UserList,
|
||||||
|
UserListItem,
|
||||||
|
} from '../lists.models';
|
||||||
import { ListsRealtimeService } from '../lists-realtime.service';
|
import { ListsRealtimeService } from '../lists-realtime.service';
|
||||||
import { ListsService } from '../lists.service';
|
import { ListsService } from '../lists.service';
|
||||||
|
|
||||||
@@ -55,8 +60,12 @@ export class ListDetailComponent implements OnInit {
|
|||||||
protected readonly saving = signal(false);
|
protected readonly saving = signal(false);
|
||||||
protected readonly editing = signal(false);
|
protected readonly editing = signal(false);
|
||||||
protected readonly addingItem = signal(false);
|
protected readonly addingItem = signal(false);
|
||||||
|
protected readonly loadingSuggestions = signal(false);
|
||||||
|
protected readonly suggestionsLoaded = signal(false);
|
||||||
protected readonly errorMessage = signal<string | null>(null);
|
protected readonly errorMessage = signal<string | null>(null);
|
||||||
protected readonly updatingItemId = signal<string | null>(null);
|
protected readonly updatingItemId = signal<string | null>(null);
|
||||||
|
protected readonly addingSuggestionTitle = signal<string | null>(null);
|
||||||
|
protected readonly itemSuggestions = signal<ListItemSuggestion[]>([]);
|
||||||
protected readonly shareSearchTerm = signal('');
|
protected readonly shareSearchTerm = signal('');
|
||||||
protected readonly shareSearchResults = signal<PublicUserSearchResult[]>([]);
|
protected readonly shareSearchResults = signal<PublicUserSearchResult[]>([]);
|
||||||
protected readonly searchingUsers = signal(false);
|
protected readonly searchingUsers = signal(false);
|
||||||
@@ -201,6 +210,63 @@ export class ListDetailComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected loadSuggestions(): void {
|
||||||
|
const listId = this.listId();
|
||||||
|
|
||||||
|
if (!listId || !this.canEditItems() || this.loadingSuggestions()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadingSuggestions.set(true);
|
||||||
|
this.suggestionsLoaded.set(false);
|
||||||
|
|
||||||
|
this.listsService
|
||||||
|
.suggestItems(listId)
|
||||||
|
.pipe(finalize(() => this.loadingSuggestions.set(false)))
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.itemSuggestions.set(response.suggestions);
|
||||||
|
this.suggestionsLoaded.set(true);
|
||||||
|
},
|
||||||
|
error: (error: unknown) => {
|
||||||
|
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected addSuggestion(suggestion: ListItemSuggestion): void {
|
||||||
|
const listId = this.listId();
|
||||||
|
|
||||||
|
if (!listId || this.addingSuggestionTitle()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addingSuggestionTitle.set(suggestion.title);
|
||||||
|
this.listsService
|
||||||
|
.addItem(listId, {
|
||||||
|
title: suggestion.title,
|
||||||
|
notes: suggestion.notes,
|
||||||
|
quantity: suggestion.quantity,
|
||||||
|
required: suggestion.required,
|
||||||
|
})
|
||||||
|
.pipe(finalize(() => this.addingSuggestionTitle.set(null)))
|
||||||
|
.subscribe({
|
||||||
|
next: (list) => {
|
||||||
|
this.setList(list);
|
||||||
|
this.itemSuggestions.update((suggestions) =>
|
||||||
|
suggestions.filter(
|
||||||
|
(itemSuggestion) =>
|
||||||
|
this.suggestionKey(itemSuggestion.title) !==
|
||||||
|
this.suggestionKey(suggestion.title),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: (error: unknown) => {
|
||||||
|
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected toggleItem(item: UserListItem, checked: boolean): void {
|
protected toggleItem(item: UserListItem, checked: boolean): void {
|
||||||
const listId = this.listId();
|
const listId = this.listId();
|
||||||
const currentList = this.list();
|
const currentList = this.list();
|
||||||
@@ -258,6 +324,10 @@ export class ListDetailComponent implements OnInit {
|
|||||||
return list.items.filter((item) => item.checked).length;
|
return list.items.filter((item) => item.checked).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected visibleItems(list: UserList): UserListItem[] {
|
||||||
|
return this.uncheckedFirst(list.items);
|
||||||
|
}
|
||||||
|
|
||||||
protected searchShareUsers(term: string): void {
|
protected searchShareUsers(term: string): void {
|
||||||
this.shareSearchTerm.set(term);
|
this.shareSearchTerm.set(term);
|
||||||
|
|
||||||
@@ -374,4 +444,21 @@ export class ListDetailComponent implements OnInit {
|
|||||||
private listId(): string | null {
|
private listId(): string | null {
|
||||||
return this.route.snapshot.paramMap.get('listId');
|
return this.route.snapshot.paramMap.get('listId');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private uncheckedFirst(items: UserListItem[]): UserListItem[] {
|
||||||
|
return items
|
||||||
|
.map((item, index) => ({ item, index }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.item.checked !== b.item.checked) {
|
||||||
|
return a.item.checked ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.index - b.index;
|
||||||
|
})
|
||||||
|
.map(({ item }) => item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private suggestionKey(value: string): string {
|
||||||
|
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
|
|
||||||
@if (list.items.length > 0) {
|
@if (list.items.length > 0) {
|
||||||
<ul class="template-items">
|
<ul class="template-items">
|
||||||
@for (item of list.items.slice(0, 4); track item.id) {
|
@for (item of previewItems(list); track item.id) {
|
||||||
<li>
|
<li>
|
||||||
<mat-icon aria-hidden="true">
|
<mat-icon aria-hidden="true">
|
||||||
{{ item.checked ? 'check_circle' : 'radio_button_unchecked' }}
|
{{ item.checked ? 'check_circle' : 'radio_button_unchecked' }}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { MatSelectModule } from '@angular/material/select';
|
|||||||
import { getAuthErrorMessage } from '../auth/error-message';
|
import { getAuthErrorMessage } from '../auth/error-message';
|
||||||
import { OnboardingService } from '../onboarding/onboarding.service';
|
import { OnboardingService } from '../onboarding/onboarding.service';
|
||||||
import { ListTemplateKind } from '../templates/templates.models';
|
import { ListTemplateKind } from '../templates/templates.models';
|
||||||
import { ListRealtimeEvent, UserList } from './lists.models';
|
import { ListRealtimeEvent, UserList, UserListItem } from './lists.models';
|
||||||
import { ListsRealtimeService } from './lists-realtime.service';
|
import { ListsRealtimeService } from './lists-realtime.service';
|
||||||
import { ListsService } from './lists.service';
|
import { ListsService } from './lists.service';
|
||||||
|
|
||||||
@@ -152,6 +152,10 @@ export class ListsComponent implements OnInit {
|
|||||||
return list.items.filter((item) => item.checked).length;
|
return list.items.filter((item) => item.checked).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected previewItems(list: UserList): UserListItem[] {
|
||||||
|
return this.uncheckedFirst(list.items).slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
protected progressLabel(list: UserList): string {
|
protected progressLabel(list: UserList): string {
|
||||||
if (list.items.length === 0) {
|
if (list.items.length === 0) {
|
||||||
return '0%';
|
return '0%';
|
||||||
@@ -230,4 +234,17 @@ export class ListsComponent implements OnInit {
|
|||||||
private dateValue(value: string): number {
|
private dateValue(value: string): number {
|
||||||
return new Date(value).getTime();
|
return new Date(value).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private uncheckedFirst(items: UserListItem[]): UserListItem[] {
|
||||||
|
return items
|
||||||
|
.map((item, index) => ({ item, index }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.item.checked !== b.item.checked) {
|
||||||
|
return a.item.checked ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.index - b.index;
|
||||||
|
})
|
||||||
|
.map(({ item }) => item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ export interface AddListItemRequest {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListItemSuggestion {
|
||||||
|
title: string;
|
||||||
|
notes?: string;
|
||||||
|
quantity?: number;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListItemSuggestionsResponse {
|
||||||
|
suggestions: ListItemSuggestion[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateListItemRequest {
|
export interface UpdateListItemRequest {
|
||||||
title?: string;
|
title?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Observable } from 'rxjs';
|
|||||||
import {
|
import {
|
||||||
AddListItemRequest,
|
AddListItemRequest,
|
||||||
CreateListRequest,
|
CreateListRequest,
|
||||||
|
ListItemSuggestionsResponse,
|
||||||
UpdateListItemRequest,
|
UpdateListItemRequest,
|
||||||
UpdateListRequest,
|
UpdateListRequest,
|
||||||
UserList,
|
UserList,
|
||||||
@@ -42,6 +43,13 @@ export class ListsService {
|
|||||||
return this.http.post<UserList>(`${this.apiUrl}/${listId}/items`, data);
|
return this.http.post<UserList>(`${this.apiUrl}/${listId}/items`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suggestItems(listId: string): Observable<ListItemSuggestionsResponse> {
|
||||||
|
return this.http.post<ListItemSuggestionsResponse>(
|
||||||
|
`${this.apiUrl}/${listId}/item-suggestions`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
updateItem(
|
updateItem(
|
||||||
listId: string,
|
listId: string,
|
||||||
itemId: string,
|
itemId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user