mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-05-23 12:58:18 -05:00
Extremely basic chat component
This commit is contained in:
parent
b223d30c6c
commit
9df25c4365
@ -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()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
@ -5,26 +5,18 @@ import {
|
|||||||
HttpRequest,
|
HttpRequest,
|
||||||
} from '@angular/common/http'
|
} from '@angular/common/http'
|
||||||
import { Injectable } from '@angular/core'
|
import { 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 {
|
||||||
constructor(
|
constructor(private csrfService: CsrfService) {}
|
||||||
private cookieService: CookieService,
|
|
||||||
private meta: 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,
|
||||||
@ -254,6 +255,7 @@ const icons = {
|
|||||||
caretDown,
|
caretDown,
|
||||||
caretUp,
|
caretUp,
|
||||||
chatLeftText,
|
chatLeftText,
|
||||||
|
chatSquareDots,
|
||||||
check,
|
check,
|
||||||
check2All,
|
check2All,
|
||||||
checkAll,
|
checkAll,
|
||||||
|
@ -584,6 +584,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",
|
||||||
@ -594,6 +598,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