chat
This commit is contained in:
@@ -18,6 +18,9 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
|||||||
# Browser-URL, unter der der Container erreichbar ist.
|
# Browser-URL, unter der der Container erreichbar ist.
|
||||||
CLIENT_URL=http://localhost:8080
|
CLIENT_URL=http://localhost:8080
|
||||||
|
|
||||||
|
MISTRAL_API_KEY=
|
||||||
|
MISTRAL_MODEL=mistral-small-latest
|
||||||
|
|
||||||
MAIL_ENABLED=true
|
MAIL_ENABLED=true
|
||||||
SMTP_HOST=host.docker.internal
|
SMTP_HOST=host.docker.internal
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
|||||||
|
|
||||||
CLIENT_URL=http://localhost:4200
|
CLIENT_URL=http://localhost:4200
|
||||||
|
|
||||||
|
MISTRAL_API_KEY=
|
||||||
|
MISTRAL_MODEL=mistral-small-latest
|
||||||
|
|
||||||
MAIL_ENABLED=true
|
MAIL_ENABLED=true
|
||||||
SMTP_HOST=localhost
|
SMTP_HOST=localhost
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ $ npm run start:dev
|
|||||||
$ npm run start:prod
|
$ npm run start:prod
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Mistral assistant
|
||||||
|
|
||||||
|
The in-app assistant calls Mistral from the API server. Configure the key in the API environment, never in the Angular client:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MISTRAL_API_KEY=your-mistral-api-key
|
||||||
|
MISTRAL_MODEL=mistral-small-latest
|
||||||
|
```
|
||||||
|
|
||||||
|
The authenticated frontend calls `POST /api/assistant/chat`; the API then calls Mistral and may execute allowed Listify actions such as creating lists or adding list items.
|
||||||
|
|
||||||
## Run tests
|
## Run tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
import { AssistantModule } from './assistant/assistant.module';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { ListTemplatesModule } from './list-templates/list-templates.module';
|
import { ListTemplatesModule } from './list-templates/list-templates.module';
|
||||||
@@ -52,6 +53,7 @@ import { DatabaseLogger } from './database/database.logger';
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
|
AssistantModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
|
|||||||
32
listify-api/src/assistant/assistant.controller.ts
Normal file
32
listify-api/src/assistant/assistant.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UnauthorizedException,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { AssistantService } from './assistant.service';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.types';
|
||||||
|
import type { AssistantChatRequest } from './assistant.types';
|
||||||
|
|
||||||
|
@Controller('assistant')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class AssistantController {
|
||||||
|
constructor(private readonly assistantService: AssistantService) {}
|
||||||
|
|
||||||
|
@Post('chat')
|
||||||
|
chat(
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@Body() body: AssistantChatRequest,
|
||||||
|
) {
|
||||||
|
const userId = request.user?.sub;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new UnauthorizedException('Authenticated user is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.assistantService.chat(userId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
listify-api/src/assistant/assistant.module.ts
Normal file
12
listify-api/src/assistant/assistant.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { ListsModule } from '../lists/lists.module';
|
||||||
|
import { AssistantController } from './assistant.controller';
|
||||||
|
import { AssistantService } from './assistant.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, ListsModule],
|
||||||
|
controllers: [AssistantController],
|
||||||
|
providers: [AssistantService],
|
||||||
|
})
|
||||||
|
export class AssistantModule {}
|
||||||
220
listify-api/src/assistant/assistant.service.spec.ts
Normal file
220
listify-api/src/assistant/assistant.service.spec.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { UserList } from '../list-templates/list-template.types';
|
||||||
|
import { ListsService } from '../lists/lists.service';
|
||||||
|
import { AssistantService } from './assistant.service';
|
||||||
|
|
||||||
|
describe('AssistantService', () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
const originalApiKey = process.env.MISTRAL_API_KEY;
|
||||||
|
let listsService: Pick<ListsService, 'listLists' | 'createList' | 'addItem'>;
|
||||||
|
let service: AssistantService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.MISTRAL_API_KEY = 'test-key';
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
listsService = {
|
||||||
|
listLists: jest.fn(),
|
||||||
|
createList: jest.fn(),
|
||||||
|
addItem: jest.fn(),
|
||||||
|
};
|
||||||
|
service = new AssistantService(listsService as ListsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
process.env.MISTRAL_API_KEY = originalApiKey;
|
||||||
|
delete process.env.MISTRAL_MODEL;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a plain assistant response without tool calls', async () => {
|
||||||
|
mockMistralResponse({
|
||||||
|
choices: [{ message: { content: 'Klar, ich helfe dir.' } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.chat('user-1', {
|
||||||
|
messages: [{ role: 'user', content: 'Hallo' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
message: { role: 'assistant', content: 'Klar, ich helfe dir.' },
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes create_list tool calls and returns the final assistant response', async () => {
|
||||||
|
const createdList = list({ id: 'list-1', name: 'Sommerurlaub' });
|
||||||
|
const completedList = list({
|
||||||
|
id: 'list-1',
|
||||||
|
name: 'Sommerurlaub',
|
||||||
|
items: ['Pass'],
|
||||||
|
});
|
||||||
|
jest.mocked(listsService.createList).mockResolvedValue(createdList);
|
||||||
|
jest.mocked(listsService.addItem).mockResolvedValue(completedList);
|
||||||
|
mockMistralResponse(
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: '',
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: 'call-1',
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'create_list',
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
name: 'Sommerurlaub',
|
||||||
|
kind: 'packing',
|
||||||
|
items: [{ title: 'Pass' }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: 'Ich habe die Packliste Sommerurlaub erstellt.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.chat('user-1', {
|
||||||
|
messages: [{ role: 'user', content: 'Erstelle eine Packliste' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listsService.createList).toHaveBeenCalledWith('user-1', {
|
||||||
|
name: 'Sommerurlaub',
|
||||||
|
description: undefined,
|
||||||
|
kind: 'packing',
|
||||||
|
});
|
||||||
|
expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', {
|
||||||
|
title: 'Pass',
|
||||||
|
notes: undefined,
|
||||||
|
quantity: undefined,
|
||||||
|
required: undefined,
|
||||||
|
});
|
||||||
|
expect(result.actions).toEqual([
|
||||||
|
{
|
||||||
|
type: 'list.created',
|
||||||
|
listId: 'list-1',
|
||||||
|
list: completedList,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.message.content).toBe(
|
||||||
|
'Ich habe die Packliste Sommerurlaub erstellt.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes add_list_item tool calls', async () => {
|
||||||
|
const updatedList = list({
|
||||||
|
id: 'list-1',
|
||||||
|
name: 'Einkauf',
|
||||||
|
items: ['Milch'],
|
||||||
|
});
|
||||||
|
jest.mocked(listsService.addItem).mockResolvedValue(updatedList);
|
||||||
|
mockMistralResponse(
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: '',
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: 'call-1',
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'add_list_item',
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
listId: 'list-1',
|
||||||
|
title: 'Milch',
|
||||||
|
quantity: 2,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
choices: [{ message: { content: 'Milch wurde hinzugefuegt.' } }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.chat('user-1', {
|
||||||
|
messages: [{ role: 'user', content: 'Fuege Milch hinzu' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', {
|
||||||
|
title: 'Milch',
|
||||||
|
notes: undefined,
|
||||||
|
quantity: 2,
|
||||||
|
required: undefined,
|
||||||
|
});
|
||||||
|
expect(result.actions[0]).toEqual({
|
||||||
|
type: 'list.item_added',
|
||||||
|
listId: 'list-1',
|
||||||
|
itemTitle: 'Milch',
|
||||||
|
list: updatedList,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails clearly when the api key is missing', async () => {
|
||||||
|
delete process.env.MISTRAL_API_KEY;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.chat('user-1', {
|
||||||
|
messages: [{ role: 'user', content: 'Hallo' }],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(ServiceUnavailableException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockMistralResponse(...responses: object[]): void {
|
||||||
|
jest.mocked(global.fetch).mockImplementation(async () => {
|
||||||
|
const response = responses.shift() ?? responses[responses.length - 1];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => response,
|
||||||
|
} as Response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function list(options: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
items?: string[];
|
||||||
|
}): UserList {
|
||||||
|
return {
|
||||||
|
id: options.id,
|
||||||
|
ownerId: 'user-1',
|
||||||
|
accessRole: 'owner',
|
||||||
|
name: options.name,
|
||||||
|
kind: 'custom',
|
||||||
|
items: (options.items ?? []).map((title, position) => ({
|
||||||
|
id: `item-${position}`,
|
||||||
|
title,
|
||||||
|
required: true,
|
||||||
|
checked: false,
|
||||||
|
position,
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
})),
|
||||||
|
collaborators: [],
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function now(): string {
|
||||||
|
return new Date(0).toISOString();
|
||||||
|
}
|
||||||
429
listify-api/src/assistant/assistant.service.ts
Normal file
429
listify-api/src/assistant/assistant.service.ts
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ListTemplateKind } from '../list-templates/list-template.types';
|
||||||
|
import { ListsService } from '../lists/lists.service';
|
||||||
|
import {
|
||||||
|
AddListItemToolInput,
|
||||||
|
AssistantAction,
|
||||||
|
AssistantChatMessage,
|
||||||
|
AssistantChatRequest,
|
||||||
|
AssistantChatResponse,
|
||||||
|
CreateListToolInput,
|
||||||
|
} from './assistant.types';
|
||||||
|
|
||||||
|
type MistralRole = 'system' | 'user' | 'assistant' | 'tool';
|
||||||
|
|
||||||
|
interface MistralMessage {
|
||||||
|
role: MistralRole;
|
||||||
|
content: string | null;
|
||||||
|
tool_call_id?: string;
|
||||||
|
tool_calls?: MistralToolCall[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MistralToolCall {
|
||||||
|
id: string;
|
||||||
|
type: 'function';
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MistralChatResponse {
|
||||||
|
choices?: Array<{
|
||||||
|
message?: {
|
||||||
|
content?: string | null;
|
||||||
|
tool_calls?: MistralToolCall[];
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssistantService {
|
||||||
|
private readonly endpoint = 'https://api.mistral.ai/v1/chat/completions';
|
||||||
|
|
||||||
|
constructor(private readonly listsService: ListsService) {}
|
||||||
|
|
||||||
|
async chat(
|
||||||
|
userId: string,
|
||||||
|
request: AssistantChatRequest,
|
||||||
|
): Promise<AssistantChatResponse> {
|
||||||
|
const messages = this.normalizeMessages(request.messages);
|
||||||
|
const firstResponse = await this.callMistral([
|
||||||
|
this.systemMessage(),
|
||||||
|
...messages.map((message) => ({
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
const firstMessage = this.extractMessage(firstResponse);
|
||||||
|
const actions: AssistantAction[] = [];
|
||||||
|
const toolCalls = firstMessage.tool_calls ?? [];
|
||||||
|
|
||||||
|
if (toolCalls.length === 0) {
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: this.normalizeAssistantContent(firstMessage.content),
|
||||||
|
},
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolMessages: MistralMessage[] = [];
|
||||||
|
|
||||||
|
for (const toolCall of toolCalls) {
|
||||||
|
const toolResult = await this.executeToolCall(userId, toolCall, actions);
|
||||||
|
toolMessages.push({
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: toolCall.id,
|
||||||
|
content: JSON.stringify(toolResult),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalResponse = await this.callMistral([
|
||||||
|
this.systemMessage(),
|
||||||
|
...messages.map((message) => ({
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: firstMessage.content ?? '',
|
||||||
|
tool_calls: toolCalls,
|
||||||
|
},
|
||||||
|
...toolMessages,
|
||||||
|
]);
|
||||||
|
const finalMessage = this.extractMessage(finalResponse);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: this.normalizeAssistantContent(finalMessage.content),
|
||||||
|
},
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeToolCall(
|
||||||
|
userId: string,
|
||||||
|
toolCall: MistralToolCall,
|
||||||
|
actions: AssistantAction[],
|
||||||
|
): Promise<object> {
|
||||||
|
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||||
|
|
||||||
|
if (toolCall.function.name === 'list_existing_lists') {
|
||||||
|
return {
|
||||||
|
lists: (await this.listsService.listLists(userId)).map((list) => ({
|
||||||
|
id: list.id,
|
||||||
|
name: list.name,
|
||||||
|
description: list.description,
|
||||||
|
kind: list.kind,
|
||||||
|
itemCount: list.items.length,
|
||||||
|
items: list.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
required: item.required,
|
||||||
|
checked: item.checked,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolCall.function.name === 'create_list') {
|
||||||
|
const input = this.normalizeCreateListInput(args);
|
||||||
|
let list = await this.listsService.createList(userId, {
|
||||||
|
name: input.name!,
|
||||||
|
description: input.description,
|
||||||
|
kind: input.kind,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of input.items ?? []) {
|
||||||
|
list = await this.listsService.addItem(userId, list.id, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push({
|
||||||
|
type: 'list.created',
|
||||||
|
listId: list.id,
|
||||||
|
list,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { list };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolCall.function.name === 'add_list_item') {
|
||||||
|
const input = this.normalizeAddListItemInput(args);
|
||||||
|
const list = await this.listsService.addItem(userId, input.listId!, {
|
||||||
|
title: input.title!,
|
||||||
|
notes: input.notes,
|
||||||
|
quantity: input.quantity,
|
||||||
|
required: input.required,
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.push({
|
||||||
|
type: 'list.item_added',
|
||||||
|
listId: list.id,
|
||||||
|
itemTitle: input.title!,
|
||||||
|
list,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { list };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: `Unsupported tool: ${toolCall.function.name}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callMistral(messages: MistralMessage[]) {
|
||||||
|
const apiKey = process.env.MISTRAL_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new ServiceUnavailableException('Mistral API key is not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(this.endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: process.env.MISTRAL_MODEL ?? 'mistral-small-latest',
|
||||||
|
messages,
|
||||||
|
tools: this.tools(),
|
||||||
|
tool_choice: 'auto',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ServiceUnavailableException('Mistral API request failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as MistralChatResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMessages(
|
||||||
|
messages: AssistantChatMessage[] | undefined,
|
||||||
|
): AssistantChatMessage[] {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) {
|
||||||
|
throw new BadRequestException('At least one chat message is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.slice(-12).map((message) => {
|
||||||
|
if (message.role !== 'user' && message.role !== 'assistant') {
|
||||||
|
throw new BadRequestException('Chat message role is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = message.content?.trim();
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
throw new BadRequestException('Chat message content is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { role: message.role, content };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeCreateListInput(value: unknown): CreateListToolInput {
|
||||||
|
const input = this.objectValue(value);
|
||||||
|
const name = this.requiredString(input.name, 'name');
|
||||||
|
const description = this.optionalString(input.description);
|
||||||
|
const kind = this.optionalKind(input.kind);
|
||||||
|
const rawItems = input.items;
|
||||||
|
|
||||||
|
if (rawItems !== undefined && !Array.isArray(rawItems)) {
|
||||||
|
throw new BadRequestException('items must be an array.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
kind,
|
||||||
|
items: rawItems
|
||||||
|
?.slice(0, 50)
|
||||||
|
.map((item) => this.normalizeAddListItemInput(item, false)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeAddListItemInput(
|
||||||
|
value: unknown,
|
||||||
|
requireListId = true,
|
||||||
|
): AddListItemToolInput {
|
||||||
|
const input = this.objectValue(value);
|
||||||
|
const title = this.requiredString(input.title, 'title');
|
||||||
|
const listId = requireListId
|
||||||
|
? this.requiredString(input.listId, 'listId')
|
||||||
|
: undefined;
|
||||||
|
const notes = this.optionalString(input.notes);
|
||||||
|
const quantity =
|
||||||
|
input.quantity === undefined ? undefined : Number(input.quantity);
|
||||||
|
|
||||||
|
if (
|
||||||
|
quantity !== undefined &&
|
||||||
|
(!Number.isFinite(quantity) || quantity <= 0)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('quantity must be greater than zero.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.required !== undefined &&
|
||||||
|
typeof input.required !== 'boolean'
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('required must be a boolean.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
listId,
|
||||||
|
title,
|
||||||
|
notes,
|
||||||
|
quantity,
|
||||||
|
required: input.required,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseToolArguments(value: string): unknown {
|
||||||
|
try {
|
||||||
|
return value ? (JSON.parse(value) as unknown) : {};
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('Tool arguments must be valid JSON.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private objectValue(value: unknown): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new BadRequestException('Tool arguments must be an object.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requiredString(value: unknown, field: string): string {
|
||||||
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
throw new BadRequestException(`${field} is required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private optionalString(value: unknown): string | undefined {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private optionalKind(value: unknown): ListTemplateKind | undefined {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
value === 'packing' ||
|
||||||
|
value === 'shopping' ||
|
||||||
|
value === 'todo' ||
|
||||||
|
value === 'custom'
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException('kind is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractMessage(response: MistralChatResponse) {
|
||||||
|
const message = response.choices?.[0]?.message;
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
throw new ServiceUnavailableException('Mistral response was empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeAssistantContent(content: string | null | undefined): string {
|
||||||
|
return content?.trim() || 'Ich habe die Anfrage verarbeitet.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private systemMessage(): MistralMessage {
|
||||||
|
return {
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'Du bist der Listify-Assistent. Antworte knapp und hilfreich auf Deutsch. ' +
|
||||||
|
'Nutze Tools, wenn der Nutzer Listen sehen, neue Listen erstellen oder Items hinzufuegen moechte. ' +
|
||||||
|
'Erstelle oder aendere nur, wenn der Nutzer das klar anfordert. Loeschen, Teilen, Abhaken und Bearbeiten bestehender Items ist nicht erlaubt.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private tools() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'list_existing_lists',
|
||||||
|
description: 'Liest die Listen des angemeldeten Users.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'create_list',
|
||||||
|
description:
|
||||||
|
'Erstellt eine neue Liste mit optionalen Start-Items fuer den angemeldeten User.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['name'],
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' },
|
||||||
|
description: { type: 'string' },
|
||||||
|
kind: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['packing', 'shopping', 'todo', 'custom'],
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['title'],
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string' },
|
||||||
|
notes: { type: 'string' },
|
||||||
|
quantity: { type: 'number' },
|
||||||
|
required: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'add_list_item',
|
||||||
|
description:
|
||||||
|
'Fuegt ein Item zu einer bestehenden Liste hinzu, auf die der User Zugriff hat.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['listId', 'title'],
|
||||||
|
properties: {
|
||||||
|
listId: { type: 'string' },
|
||||||
|
title: { type: 'string' },
|
||||||
|
notes: { type: 'string' },
|
||||||
|
quantity: { type: 'number' },
|
||||||
|
required: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
45
listify-api/src/assistant/assistant.types.ts
Normal file
45
listify-api/src/assistant/assistant.types.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { ListTemplateKind, UserList } from '../list-templates/list-template.types';
|
||||||
|
|
||||||
|
export type AssistantMessageRole = 'user' | 'assistant';
|
||||||
|
|
||||||
|
export interface AssistantChatMessage {
|
||||||
|
role: AssistantMessageRole;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantChatRequest {
|
||||||
|
messages?: AssistantChatMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssistantAction =
|
||||||
|
| {
|
||||||
|
type: 'list.created';
|
||||||
|
listId: string;
|
||||||
|
list: UserList;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'list.item_added';
|
||||||
|
listId: string;
|
||||||
|
itemTitle: string;
|
||||||
|
list: UserList;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AssistantChatResponse {
|
||||||
|
message: AssistantChatMessage;
|
||||||
|
actions: AssistantAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateListToolInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
kind?: ListTemplateKind;
|
||||||
|
items?: AddListItemToolInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddListItemToolInput {
|
||||||
|
listId?: string;
|
||||||
|
title?: string;
|
||||||
|
notes?: string;
|
||||||
|
quantity?: number;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
@@ -28,8 +28,8 @@ import { UserEntity } from './user.entity';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly accessTokenExpiresIn = '15m';
|
private readonly accessTokenExpiresIn = '7d';
|
||||||
private readonly refreshTokenExpiresIn = '7d';
|
private readonly refreshTokenExpiresIn = '30d';
|
||||||
private readonly accessTokenSecret =
|
private readonly accessTokenSecret =
|
||||||
process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret';
|
process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret';
|
||||||
private readonly refreshTokenSecret =
|
private readonly refreshTokenSecret =
|
||||||
|
|||||||
@@ -88,9 +88,13 @@
|
|||||||
</mat-sidenav>
|
</mat-sidenav>
|
||||||
|
|
||||||
<mat-sidenav-content class="shell-content">
|
<mat-sidenav-content class="shell-content">
|
||||||
<main class="app-main">
|
<div class="shell-workspace">
|
||||||
<router-outlet />
|
<main class="app-main">
|
||||||
</main>
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<app-assistant-chat class="assistant-sidebar" />
|
||||||
|
</div>
|
||||||
</mat-sidenav-content>
|
</mat-sidenav-content>
|
||||||
</mat-sidenav-container>
|
</mat-sidenav-container>
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,15 @@
|
|||||||
min-height: calc(100dvh - 56px);
|
min-height: calc(100dvh - 56px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shell-workspace {
|
||||||
|
display: grid;
|
||||||
|
min-height: calc(100dvh - 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.shell .app-main {
|
.shell .app-main {
|
||||||
padding-bottom: calc(76px + env(safe-area-inset-bottom));
|
padding-bottom: calc(76px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
@@ -189,6 +198,7 @@
|
|||||||
|
|
||||||
.shell,
|
.shell,
|
||||||
.shell-content,
|
.shell-content,
|
||||||
|
.shell-workspace,
|
||||||
.app-main {
|
.app-main {
|
||||||
min-height: calc(100dvh - 64px);
|
min-height: calc(100dvh - 64px);
|
||||||
}
|
}
|
||||||
@@ -205,3 +215,16 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1041px) {
|
||||||
|
.shell-workspace {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 360px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 64px;
|
||||||
|
min-height: calc(100dvh - 64px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { MatSidenavModule } from '@angular/material/sidenav';
|
|||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { map } from 'rxjs';
|
import { map } from 'rxjs';
|
||||||
import { AuthService } from './auth/auth.service';
|
import { AuthService } from './auth/auth.service';
|
||||||
|
import { AssistantChatComponent } from './assistant/assistant-chat.component';
|
||||||
import { OnboardingOverlayComponent } from './onboarding/onboarding-overlay.component';
|
import { OnboardingOverlayComponent } from './onboarding/onboarding-overlay.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -22,6 +23,7 @@ import { OnboardingOverlayComponent } from './onboarding/onboarding-overlay.comp
|
|||||||
MatListModule,
|
MatListModule,
|
||||||
MatSidenavModule,
|
MatSidenavModule,
|
||||||
MatToolbarModule,
|
MatToolbarModule,
|
||||||
|
AssistantChatComponent,
|
||||||
OnboardingOverlayComponent,
|
OnboardingOverlayComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<section class="assistant-shell" aria-label="Listify-Assistent">
|
||||||
|
<header class="assistant-header">
|
||||||
|
<div>
|
||||||
|
<h2>Assistent</h2>
|
||||||
|
<p>Listen planen und erstellen</p>
|
||||||
|
</div>
|
||||||
|
<mat-icon aria-hidden="true">auto_awesome</mat-icon>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="message-list" aria-live="polite">
|
||||||
|
@for (message of messages(); track $index) {
|
||||||
|
<article class="message" [class.user-message]="message.role === 'user'">
|
||||||
|
<p>{{ message.content }}</p>
|
||||||
|
|
||||||
|
@if (message.actions?.length) {
|
||||||
|
<div class="action-list">
|
||||||
|
@for (action of message.actions; track action.type + action.listId) {
|
||||||
|
<a mat-stroked-button [routerLink]="['/lists', action.listId]">
|
||||||
|
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
||||||
|
{{ actionLabel(action) }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (sending()) {
|
||||||
|
<div class="assistant-loading">
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="22" />
|
||||||
|
<span>Antwort wird erstellt</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (errorMessage()) {
|
||||||
|
<p class="assistant-error">
|
||||||
|
<mat-icon aria-hidden="true">error</mat-icon>
|
||||||
|
{{ errorMessage() }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="composer">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Nachricht</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
rows="3"
|
||||||
|
[value]="draft()"
|
||||||
|
(input)="draft.set($any($event.target).value)"
|
||||||
|
(keydown.enter)="handleEnter($event)"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
type="button"
|
||||||
|
[disabled]="!canSend()"
|
||||||
|
(click)="send()"
|
||||||
|
aria-label="Nachricht senden"
|
||||||
|
>
|
||||||
|
<mat-icon aria-hidden="true">send</mat-icon>
|
||||||
|
Senden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
132
listify-client/src/app/assistant/assistant-chat.component.scss
Normal file
132
listify-client/src/app/assistant/assistant-chat.component.scss
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(220px, 1fr) auto auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 420px;
|
||||||
|
max-height: calc(100dvh - 88px);
|
||||||
|
padding: 0.85rem;
|
||||||
|
border-left: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--mat-sys-surface) 94%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-header h2,
|
||||||
|
.assistant-header p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-header h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-header p {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
color: var(--mat-sys-on-surface-variant);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-header mat-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--mat-sys-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
align-self: flex-start;
|
||||||
|
max-width: 92%;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 70%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mat-sys-surface-container-low);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: color-mix(in srgb, var(--mat-sys-primary) 12%, var(--mat-sys-surface));
|
||||||
|
color: var(--mat-sys-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message p {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list a {
|
||||||
|
justify-content: start;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-loading,
|
||||||
|
.assistant-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--mat-sys-on-surface-variant);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mat-sys-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-error mat-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer mat-form-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer textarea {
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer button {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1040px) {
|
||||||
|
.assistant-shell {
|
||||||
|
max-height: none;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
listify-client/src/app/assistant/assistant-chat.component.ts
Normal file
106
listify-client/src/app/assistant/assistant-chat.component.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Component, computed, inject, signal } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { finalize } from 'rxjs';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
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 { getAuthErrorMessage } from '../auth/error-message';
|
||||||
|
import {
|
||||||
|
AssistantAction,
|
||||||
|
AssistantChatMessage,
|
||||||
|
AssistantConversationMessage,
|
||||||
|
} from './assistant.models';
|
||||||
|
import { AssistantService } from './assistant.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-assistant-chat',
|
||||||
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
],
|
||||||
|
templateUrl: './assistant-chat.component.html',
|
||||||
|
styleUrl: './assistant-chat.component.scss',
|
||||||
|
})
|
||||||
|
export class AssistantChatComponent {
|
||||||
|
private readonly assistantService = inject(AssistantService);
|
||||||
|
|
||||||
|
protected readonly messages = signal<AssistantConversationMessage[]>([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Hallo, ich bin dein Listify-Assistent. Was soll ich vorbereiten?',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
protected readonly draft = signal('');
|
||||||
|
protected readonly sending = signal(false);
|
||||||
|
protected readonly errorMessage = signal<string | null>(null);
|
||||||
|
protected readonly canSend = computed(
|
||||||
|
() => this.draft().trim().length > 0 && !this.sending(),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected send(): void {
|
||||||
|
const content = this.draft().trim();
|
||||||
|
|
||||||
|
if (!content || this.sending()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMessage: AssistantConversationMessage = {
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
const nextMessages = [...this.messages(), userMessage];
|
||||||
|
|
||||||
|
this.messages.set(nextMessages);
|
||||||
|
this.draft.set('');
|
||||||
|
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)))
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.messages.update((messages) => [
|
||||||
|
...messages,
|
||||||
|
{
|
||||||
|
...response.message,
|
||||||
|
actions: response.actions,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
error: (error: unknown) => {
|
||||||
|
this.errorMessage.set(getAuthErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected handleEnter(event: Event): void {
|
||||||
|
const keyboardEvent = event as KeyboardEvent;
|
||||||
|
|
||||||
|
if (keyboardEvent.shiftKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboardEvent.preventDefault();
|
||||||
|
this.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected actionLabel(action: AssistantAction): string {
|
||||||
|
if (action.type === 'list.created') {
|
||||||
|
return `Liste erstellt: ${action.list.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Item hinzugefuegt: ${action.itemTitle}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
listify-client/src/app/assistant/assistant.models.ts
Normal file
34
listify-client/src/app/assistant/assistant.models.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { UserList } from '../lists/lists.models';
|
||||||
|
|
||||||
|
export type AssistantMessageRole = 'user' | 'assistant';
|
||||||
|
|
||||||
|
export interface AssistantChatMessage {
|
||||||
|
role: AssistantMessageRole;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssistantAction =
|
||||||
|
| {
|
||||||
|
type: 'list.created';
|
||||||
|
listId: string;
|
||||||
|
list: UserList;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'list.item_added';
|
||||||
|
listId: string;
|
||||||
|
itemTitle: string;
|
||||||
|
list: UserList;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AssistantChatRequest {
|
||||||
|
messages: AssistantChatMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantChatResponse {
|
||||||
|
message: AssistantChatMessage;
|
||||||
|
actions: AssistantAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantConversationMessage extends AssistantChatMessage {
|
||||||
|
actions?: AssistantAction[];
|
||||||
|
}
|
||||||
14
listify-client/src/app/assistant/assistant.service.ts
Normal file
14
listify-client/src/app/assistant/assistant.service.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AssistantChatRequest, AssistantChatResponse } from './assistant.models';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AssistantService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly apiUrl = '/api/assistant';
|
||||||
|
|
||||||
|
chat(request: AssistantChatRequest): Observable<AssistantChatResponse> {
|
||||||
|
return this.http.post<AssistantChatResponse>(`${this.apiUrl}/chat`, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user