mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-31 10:37:12 -04:00 
			
		
		
		
	Extremely basic chat component
This commit is contained in:
		
							parent
							
								
									bb3336f7bc
								
							
						
					
					
						commit
						c809a65571
					
				| @ -30,6 +30,7 @@ | ||||
|     </div> | ||||
|   </div> | ||||
|   <ul ngbNav class="order-sm-3"> | ||||
|     <pngx-chat></pngx-chat> | ||||
|     <pngx-toasts-dropdown></pngx-toasts-dropdown> | ||||
|     <li ngbDropdown class="nav-item dropdown"> | ||||
|       <button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle> | ||||
|  | ||||
| @ -44,6 +44,7 @@ import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { TasksService } from 'src/app/services/tasks.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { ChatComponent } from '../chat/chat/chat.component' | ||||
| import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' | ||||
| import { DocumentDetailComponent } from '../document-detail/document-detail.component' | ||||
| import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' | ||||
| @ -59,6 +60,7 @@ import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.compo | ||||
|     DocumentTitlePipe, | ||||
|     IfPermissionsDirective, | ||||
|     ToastsDropdownComponent, | ||||
|     ChatComponent, | ||||
|     RouterModule, | ||||
|     NgClass, | ||||
|     NgbDropdownModule, | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| 
 | ||||
| <li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)"> | ||||
| <li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)"> | ||||
|   @if (toasts.length) { | ||||
|     <span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span> | ||||
|   } | ||||
|  | ||||
							
								
								
									
										31
									
								
								src-ui/src/app/components/chat/chat/chat.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src-ui/src/app/components/chat/chat/chat.component.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | ||||
| 
 | ||||
| <li ngbDropdown class="nav-item me-n2" (openChange)="onOpenChange($event)"> | ||||
|   <button class="btn border-0" id="chatDropdown" ngbDropdownToggle> | ||||
|     <i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs> | ||||
|   </button> | ||||
|   <div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown"> | ||||
|     <div class="chat-container bg-light p-2"> | ||||
|       <div class="chat-messages font-monospace small"> | ||||
|         @for (message of messages; track message) { | ||||
|           <div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'"> | ||||
|             <span class="p-2 m-2" [class.bg-dark]="message.role === 'user'">{{ message.content }}</span> | ||||
|           </div> | ||||
|         } | ||||
|         <div #scrollAnchor></div> | ||||
|       </div> | ||||
| 
 | ||||
|       <form class="chat-input"> | ||||
|         <div class="input-group"> | ||||
|           <input | ||||
|             #inputField | ||||
|             class="form-control form-control-sm" name="chatInput" type="text" placeholder="Ask about this document..." | ||||
|             [disabled]="loading" | ||||
|             [(ngModel)]="input" | ||||
|             (keydown)="searchInputKeyDown($event)" | ||||
|             /> | ||||
|           <button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading">Send</button> | ||||
|         </div> | ||||
|       </form> | ||||
|     </div> | ||||
|   </div> | ||||
| </li> | ||||
							
								
								
									
										22
									
								
								src-ui/src/app/components/chat/chat/chat.component.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src-ui/src/app/components/chat/chat/chat.component.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,22 @@ | ||||
| .dropdown-menu { | ||||
|   width: var(--pngx-toast-max-width); | ||||
| } | ||||
| 
 | ||||
| .chat-messages { | ||||
|   max-height: 350px; | ||||
|   overflow-y: auto; | ||||
| } | ||||
| 
 | ||||
| .dropdown-toggle::after { | ||||
|   display: none; | ||||
| } | ||||
| 
 | ||||
| .dropdown-item { | ||||
|   white-space: initial; | ||||
| } | ||||
| 
 | ||||
| @media screen and (max-width: 400px) { | ||||
|   :host ::ng-deep .dropdown-menu-end { | ||||
|     right: -3rem; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										91
									
								
								src-ui/src/app/components/chat/chat/chat.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src-ui/src/app/components/chat/chat/chat.component.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,91 @@ | ||||
| import { NgClass } from '@angular/common' | ||||
| import { Component, ElementRef, ViewChild } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { ChatMessage, ChatService } from 'src/app/services/chat.service' | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'pngx-chat', | ||||
|   imports: [ | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|     NgbDropdownModule, | ||||
|     NgClass, | ||||
|   ], | ||||
|   templateUrl: './chat.component.html', | ||||
|   styleUrl: './chat.component.scss', | ||||
| }) | ||||
| export class ChatComponent { | ||||
|   messages: ChatMessage[] = [] | ||||
|   loading = false | ||||
|   documentId = 295 // Replace this with actual doc ID logic
 | ||||
|   input: string = '' | ||||
|   @ViewChild('scrollAnchor') scrollAnchor!: ElementRef<HTMLDivElement> | ||||
|   @ViewChild('inputField') inputField!: ElementRef<HTMLInputElement> | ||||
| 
 | ||||
|   constructor(private chatService: ChatService) {} | ||||
| 
 | ||||
|   sendMessage(): void { | ||||
|     if (!this.input.trim()) return | ||||
| 
 | ||||
|     const userMessage: ChatMessage = { role: 'user', content: this.input } | ||||
|     this.messages.push(userMessage) | ||||
| 
 | ||||
|     const assistantMessage: ChatMessage = { | ||||
|       role: 'assistant', | ||||
|       content: '', | ||||
|       isStreaming: true, | ||||
|     } | ||||
|     this.messages.push(assistantMessage) | ||||
|     this.loading = true | ||||
| 
 | ||||
|     this.chatService.streamChat(this.documentId, this.input).subscribe({ | ||||
|       next: (chunk) => { | ||||
|         assistantMessage.content += chunk | ||||
|         this.scrollToBottom() | ||||
|       }, | ||||
|       error: () => { | ||||
|         assistantMessage.content += '\n\n⚠️ Error receiving response.' | ||||
|         assistantMessage.isStreaming = false | ||||
|         this.loading = false | ||||
|       }, | ||||
|       complete: () => { | ||||
|         assistantMessage.isStreaming = false | ||||
|         this.loading = false | ||||
|         this.scrollToBottom() | ||||
|       }, | ||||
|     }) | ||||
| 
 | ||||
|     this.input = '' | ||||
|   } | ||||
| 
 | ||||
|   scrollToBottom(): void { | ||||
|     setTimeout(() => { | ||||
|       this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' }) | ||||
|     }, 50) | ||||
|   } | ||||
| 
 | ||||
|   onOpenChange(open: boolean): void { | ||||
|     if (open) { | ||||
|       setTimeout(() => { | ||||
|         this.inputField.nativeElement.focus() | ||||
|       }, 10) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public searchInputKeyDown(event: KeyboardEvent) { | ||||
|     if (event.key === 'Enter') { | ||||
|       event.preventDefault() | ||||
|       this.sendMessage() | ||||
|     } | ||||
|     // } else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
 | ||||
|     //   if (this.query?.length) {
 | ||||
|     //     this.reset(true)
 | ||||
|     //   } else {
 | ||||
|     //     this.searchInput.nativeElement.blur()
 | ||||
|     //   }
 | ||||
|     // }
 | ||||
|   } | ||||
| } | ||||
| @ -4,25 +4,19 @@ import { | ||||
|   HttpInterceptor, | ||||
|   HttpRequest, | ||||
| } from '@angular/common/http' | ||||
| import { Injectable, inject } from '@angular/core' | ||||
| import { Meta } from '@angular/platform-browser' | ||||
| import { CookieService } from 'ngx-cookie-service' | ||||
| import { inject, Injectable } from '@angular/core' | ||||
| import { Observable } from 'rxjs' | ||||
| import { CsrfService } from '../services/csrf.service' | ||||
| 
 | ||||
| @Injectable() | ||||
| export class CsrfInterceptor implements HttpInterceptor { | ||||
|   private cookieService = inject(CookieService) | ||||
|   private meta = inject(Meta) | ||||
|   private csrfService = inject(CsrfService) | ||||
| 
 | ||||
|   intercept( | ||||
|     request: HttpRequest<unknown>, | ||||
|     next: HttpHandler | ||||
|   ): Observable<HttpEvent<unknown>> { | ||||
|     let prefix = '' | ||||
|     if (this.meta.getTag('name=cookie_prefix')) { | ||||
|       prefix = this.meta.getTag('name=cookie_prefix').content | ||||
|     } | ||||
|     let csrfToken = this.cookieService.get(`${prefix}csrftoken`) | ||||
|     const csrfToken = this.csrfService.getToken() | ||||
|     if (csrfToken) { | ||||
|       request = request.clone({ | ||||
|         setHeaders: { | ||||
|  | ||||
							
								
								
									
										60
									
								
								src-ui/src/app/services/chat.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src-ui/src/app/services/chat.service.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,60 @@ | ||||
| import { Injectable } from '@angular/core' | ||||
| import { Observable } from 'rxjs' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { CsrfService } from './csrf.service' | ||||
| 
 | ||||
| export interface ChatMessage { | ||||
|   role: 'user' | 'assistant' | ||||
|   content: string | ||||
|   isStreaming?: boolean | ||||
| } | ||||
| 
 | ||||
| @Injectable({ | ||||
|   providedIn: 'root', | ||||
| }) | ||||
| export class ChatService { | ||||
|   constructor(private csrfService: CsrfService) {} | ||||
| 
 | ||||
|   streamChat(documentId: number, prompt: string): Observable<string> { | ||||
|     return new Observable<string>((observer) => { | ||||
|       const url = `${environment.apiBaseUrl}documents/chat/` | ||||
|       const xhr = new XMLHttpRequest() | ||||
|       let lastLength = 0 | ||||
| 
 | ||||
|       xhr.open('POST', url) | ||||
|       xhr.setRequestHeader('Content-Type', 'application/json') | ||||
| 
 | ||||
|       xhr.withCredentials = true | ||||
|       let csrfToken = this.csrfService.getToken() | ||||
|       if (csrfToken) { | ||||
|         xhr.setRequestHeader('X-CSRFToken', csrfToken) | ||||
|       } | ||||
| 
 | ||||
|       xhr.onreadystatechange = () => { | ||||
|         if (xhr.readyState === 3 || xhr.readyState === 4) { | ||||
|           const partial = xhr.responseText.slice(lastLength) | ||||
|           lastLength = xhr.responseText.length | ||||
| 
 | ||||
|           if (partial) { | ||||
|             observer.next(partial) | ||||
|           } | ||||
|         } | ||||
| 
 | ||||
|         if (xhr.readyState === 4) { | ||||
|           observer.complete() | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       xhr.onerror = () => { | ||||
|         observer.error(new Error('Streaming request failed.')) | ||||
|       } | ||||
| 
 | ||||
|       const body = JSON.stringify({ | ||||
|         document_id: documentId, | ||||
|         q: prompt, | ||||
|       }) | ||||
| 
 | ||||
|       xhr.send(body) | ||||
|     }) | ||||
|   } | ||||
| } | ||||
							
								
								
									
										23
									
								
								src-ui/src/app/services/csrf.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src-ui/src/app/services/csrf.service.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| import { Injectable } from '@angular/core' | ||||
| import { Meta } from '@angular/platform-browser' | ||||
| import { CookieService } from 'ngx-cookie-service' // Assuming you're using this
 | ||||
| 
 | ||||
| @Injectable({ providedIn: 'root' }) | ||||
| export class CsrfService { | ||||
|   constructor( | ||||
|     private cookieService: CookieService, | ||||
|     private meta: Meta | ||||
|   ) {} | ||||
| 
 | ||||
|   public getCookiePrefix(): string { | ||||
|     let prefix = '' | ||||
|     if (this.meta.getTag('name=cookie_prefix')) { | ||||
|       prefix = this.meta.getTag('name=cookie_prefix').content | ||||
|     } | ||||
|     return prefix | ||||
|   } | ||||
| 
 | ||||
|   public getToken(): string { | ||||
|     return this.cookieService.get(`${this.getCookiePrefix()}csrftoken`) | ||||
|   } | ||||
| } | ||||
| @ -48,6 +48,7 @@ import { | ||||
|   caretDown, | ||||
|   caretUp, | ||||
|   chatLeftText, | ||||
|   chatSquareDots, | ||||
|   check, | ||||
|   check2All, | ||||
|   checkAll, | ||||
| @ -256,6 +257,7 @@ const icons = { | ||||
|   caretDown, | ||||
|   caretUp, | ||||
|   chatLeftText, | ||||
|   chatSquareDots, | ||||
|   check, | ||||
|   check2All, | ||||
|   checkAll, | ||||
|  | ||||
| @ -593,6 +593,10 @@ X_FRAME_OPTIONS = "SAMEORIGIN" | ||||
| # The next 3 settings can also be set using just PAPERLESS_URL | ||||
| CSRF_TRUSTED_ORIGINS = __get_list("PAPERLESS_CSRF_TRUSTED_ORIGINS") | ||||
| 
 | ||||
| if DEBUG: | ||||
|     # Allow access from the angular development server during debugging | ||||
|     CSRF_TRUSTED_ORIGINS.append("http://localhost:4200") | ||||
| 
 | ||||
| # We allow CORS from localhost:8000 | ||||
| CORS_ALLOWED_ORIGINS = __get_list( | ||||
|     "PAPERLESS_CORS_ALLOWED_HOSTS", | ||||
| @ -603,6 +607,8 @@ if DEBUG: | ||||
|     # Allow access from the angular development server during debugging | ||||
|     CORS_ALLOWED_ORIGINS.append("http://localhost:4200") | ||||
| 
 | ||||
| CORS_ALLOW_CREDENTIALS = True | ||||
| 
 | ||||
| CORS_EXPOSE_HEADERS = [ | ||||
|     "Content-Disposition", | ||||
| ] | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user