</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>
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'
DocumentTitlePipe,
IfPermissionsDirective,
ToastsDropdownComponent,
+ ChatComponent,
RouterModule,
NgClass,
NgbDropdownModule,
-<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>
}
--- /dev/null
+
+<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>
--- /dev/null
+.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;
+ }
+}
--- /dev/null
+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()
+ // }
+ // }
+ }
+}
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: {
--- /dev/null
+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)
+ })
+ }
+}
--- /dev/null
+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`)
+ }
+}
caretDown,
caretUp,
chatLeftText,
+ chatSquareDots,
check,
check2All,
checkAll,
caretDown,
caretUp,
chatLeftText,
+ chatSquareDots,
check,
check2All,
checkAll,
# 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",
# 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",
]