chat
This commit is contained in:
@@ -88,9 +88,13 @@
|
||||
</mat-sidenav>
|
||||
|
||||
<mat-sidenav-content class="shell-content">
|
||||
<main class="app-main">
|
||||
<router-outlet />
|
||||
</main>
|
||||
<div class="shell-workspace">
|
||||
<main class="app-main">
|
||||
<router-outlet />
|
||||
</main>
|
||||
|
||||
<app-assistant-chat class="assistant-sidebar" />
|
||||
</div>
|
||||
</mat-sidenav-content>
|
||||
</mat-sidenav-container>
|
||||
|
||||
|
||||
@@ -103,6 +103,15 @@
|
||||
min-height: calc(100dvh - 56px);
|
||||
}
|
||||
|
||||
.shell-workspace {
|
||||
display: grid;
|
||||
min-height: calc(100dvh - 56px);
|
||||
}
|
||||
|
||||
.assistant-sidebar {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shell .app-main {
|
||||
padding-bottom: calc(76px + env(safe-area-inset-bottom));
|
||||
}
|
||||
@@ -189,6 +198,7 @@
|
||||
|
||||
.shell,
|
||||
.shell-content,
|
||||
.shell-workspace,
|
||||
.app-main {
|
||||
min-height: calc(100dvh - 64px);
|
||||
}
|
||||
@@ -205,3 +215,16 @@
|
||||
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 { map } from 'rxjs';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
import { AssistantChatComponent } from './assistant/assistant-chat.component';
|
||||
import { OnboardingOverlayComponent } from './onboarding/onboarding-overlay.component';
|
||||
|
||||
@Component({
|
||||
@@ -22,6 +23,7 @@ import { OnboardingOverlayComponent } from './onboarding/onboarding-overlay.comp
|
||||
MatListModule,
|
||||
MatSidenavModule,
|
||||
MatToolbarModule,
|
||||
AssistantChatComponent,
|
||||
OnboardingOverlayComponent,
|
||||
],
|
||||
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