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> | ||||||
|   </div> |   </div> | ||||||
|   <ul ngbNav class="order-sm-3"> |   <ul ngbNav class="order-sm-3"> | ||||||
|  |     <pngx-chat></pngx-chat> | ||||||
|     <pngx-toasts-dropdown></pngx-toasts-dropdown> |     <pngx-toasts-dropdown></pngx-toasts-dropdown> | ||||||
|     <li ngbDropdown class="nav-item dropdown"> |     <li ngbDropdown class="nav-item dropdown"> | ||||||
|       <button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle> |       <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 { TasksService } from 'src/app/services/tasks.service' | ||||||
| import { ToastService } from 'src/app/services/toast.service' | import { ToastService } from 'src/app/services/toast.service' | ||||||
| import { environment } from 'src/environments/environment' | 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 { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' | ||||||
| import { DocumentDetailComponent } from '../document-detail/document-detail.component' | import { DocumentDetailComponent } from '../document-detail/document-detail.component' | ||||||
| import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' | import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' | ||||||
| @ -59,6 +60,7 @@ import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.compo | |||||||
|     DocumentTitlePipe, |     DocumentTitlePipe, | ||||||
|     IfPermissionsDirective, |     IfPermissionsDirective, | ||||||
|     ToastsDropdownComponent, |     ToastsDropdownComponent, | ||||||
|  |     ChatComponent, | ||||||
|     RouterModule, |     RouterModule, | ||||||
|     NgClass, |     NgClass, | ||||||
|     NgbDropdownModule, |     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) { |   @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> |     <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, |   HttpInterceptor, | ||||||
|   HttpRequest, |   HttpRequest, | ||||||
| } from '@angular/common/http' | } from '@angular/common/http' | ||||||
| import { Injectable, inject } from '@angular/core' | import { inject, Injectable } from '@angular/core' | ||||||
| import { Meta } from '@angular/platform-browser' |  | ||||||
| import { CookieService } from 'ngx-cookie-service' |  | ||||||
| import { Observable } from 'rxjs' | import { Observable } from 'rxjs' | ||||||
|  | import { CsrfService } from '../services/csrf.service' | ||||||
| 
 | 
 | ||||||
| @Injectable() | @Injectable() | ||||||
| export class CsrfInterceptor implements HttpInterceptor { | export class CsrfInterceptor implements HttpInterceptor { | ||||||
|   private cookieService = inject(CookieService) |   private csrfService = inject(CsrfService) | ||||||
|   private meta = inject(Meta) |  | ||||||
| 
 | 
 | ||||||
|   intercept( |   intercept( | ||||||
|     request: HttpRequest<unknown>, |     request: HttpRequest<unknown>, | ||||||
|     next: HttpHandler |     next: HttpHandler | ||||||
|   ): Observable<HttpEvent<unknown>> { |   ): Observable<HttpEvent<unknown>> { | ||||||
|     let prefix = '' |     const csrfToken = this.csrfService.getToken() | ||||||
|     if (this.meta.getTag('name=cookie_prefix')) { |  | ||||||
|       prefix = this.meta.getTag('name=cookie_prefix').content |  | ||||||
|     } |  | ||||||
|     let csrfToken = this.cookieService.get(`${prefix}csrftoken`) |  | ||||||
|     if (csrfToken) { |     if (csrfToken) { | ||||||
|       request = request.clone({ |       request = request.clone({ | ||||||
|         setHeaders: { |         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, |   caretDown, | ||||||
|   caretUp, |   caretUp, | ||||||
|   chatLeftText, |   chatLeftText, | ||||||
|  |   chatSquareDots, | ||||||
|   check, |   check, | ||||||
|   check2All, |   check2All, | ||||||
|   checkAll, |   checkAll, | ||||||
| @ -256,6 +257,7 @@ const icons = { | |||||||
|   caretDown, |   caretDown, | ||||||
|   caretUp, |   caretUp, | ||||||
|   chatLeftText, |   chatLeftText, | ||||||
|  |   chatSquareDots, | ||||||
|   check, |   check, | ||||||
|   check2All, |   check2All, | ||||||
|   checkAll, |   checkAll, | ||||||
|  | |||||||
| @ -593,6 +593,10 @@ X_FRAME_OPTIONS = "SAMEORIGIN" | |||||||
| # The next 3 settings can also be set using just PAPERLESS_URL | # The next 3 settings can also be set using just PAPERLESS_URL | ||||||
| CSRF_TRUSTED_ORIGINS = __get_list("PAPERLESS_CSRF_TRUSTED_ORIGINS") | 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 | # We allow CORS from localhost:8000 | ||||||
| CORS_ALLOWED_ORIGINS = __get_list( | CORS_ALLOWED_ORIGINS = __get_list( | ||||||
|     "PAPERLESS_CORS_ALLOWED_HOSTS", |     "PAPERLESS_CORS_ALLOWED_HOSTS", | ||||||
| @ -603,6 +607,8 @@ if DEBUG: | |||||||
|     # Allow access from the angular development server during debugging |     # Allow access from the angular development server during debugging | ||||||
|     CORS_ALLOWED_ORIGINS.append("http://localhost:4200") |     CORS_ALLOWED_ORIGINS.append("http://localhost:4200") | ||||||
| 
 | 
 | ||||||
|  | CORS_ALLOW_CREDENTIALS = True | ||||||
|  | 
 | ||||||
| CORS_EXPOSE_HEADERS = [ | CORS_EXPOSE_HEADERS = [ | ||||||
|     "Content-Disposition", |     "Content-Disposition", | ||||||
| ] | ] | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user