mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-04 03:27:12 -05: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