]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Extremely basic chat component
authorshamoon <4887959+shamoon@users.noreply.github.com>
Sat, 26 Apr 2025 02:29:51 +0000 (19:29 -0700)
committershamoon <4887959+shamoon@users.noreply.github.com>
Wed, 2 Jul 2025 18:03:07 +0000 (11:03 -0700)
src-ui/src/app/components/app-frame/app-frame.component.html
src-ui/src/app/components/app-frame/app-frame.component.ts
src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html
src-ui/src/app/components/chat/chat/chat.component.html [new file with mode: 0644]
src-ui/src/app/components/chat/chat/chat.component.scss [new file with mode: 0644]
src-ui/src/app/components/chat/chat/chat.component.ts [new file with mode: 0644]
src-ui/src/app/interceptors/csrf.interceptor.ts
src-ui/src/app/services/chat.service.ts [new file with mode: 0644]
src-ui/src/app/services/csrf.service.ts [new file with mode: 0644]
src-ui/src/main.ts
src/paperless/settings.py

index ff80288aa64c2695c2c72cb4f0eb129de62a542b..d52db0c093c8f76984b844ae0ef000e477bb1b69 100644 (file)
@@ -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>
index df3732969e4fdaedf529ddadfd0ab2f2c9af6faa..5b3e60fbbff40183fd60a53b26f8442236af2081 100644 (file)
@@ -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,
index ee27d298a6d5262df504d596814b0cc1da451de8..285cb07eabda66f3db2fac3cdc7af181972555f6 100644 (file)
@@ -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>
   }
diff --git a/src-ui/src/app/components/chat/chat/chat.component.html b/src-ui/src/app/components/chat/chat/chat.component.html
new file mode 100644 (file)
index 0000000..8a12ed4
--- /dev/null
@@ -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>
diff --git a/src-ui/src/app/components/chat/chat/chat.component.scss b/src-ui/src/app/components/chat/chat/chat.component.scss
new file mode 100644 (file)
index 0000000..9eb9dad
--- /dev/null
@@ -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;
+  }
+}
diff --git a/src-ui/src/app/components/chat/chat/chat.component.ts b/src-ui/src/app/components/chat/chat/chat.component.ts
new file mode 100644 (file)
index 0000000..a014454
--- /dev/null
@@ -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()
+    //   }
+    // }
+  }
+}
index 2f590c5eb32963d7d2264cd842c97fe958dc3487..0414f4433729a268a64a27077423543a5657ca5f 100644 (file)
@@ -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: {
diff --git a/src-ui/src/app/services/chat.service.ts b/src-ui/src/app/services/chat.service.ts
new file mode 100644 (file)
index 0000000..b32efbc
--- /dev/null
@@ -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)
+    })
+  }
+}
diff --git a/src-ui/src/app/services/csrf.service.ts b/src-ui/src/app/services/csrf.service.ts
new file mode 100644 (file)
index 0000000..28f5094
--- /dev/null
@@ -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`)
+  }
+}
index 8e58ad96e8165de0158e95bd99071358593109a3..a571e1bf724eb5fb6da4b7ef97ae2817fcab34da 100644 (file)
@@ -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,
index dc5e36ffe3f97e347fb417c3333a08c51a258926..4ad0516cee4be284642c8912ae4a67a70ba68aef 100644 (file)
@@ -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",
 ]