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