Extremely basic chat component

This commit is contained in:
shamoon 2025-04-25 19:29:51 -07:00
parent b223d30c6c
commit 9df25c4365
No known key found for this signature in database
11 changed files with 242 additions and 12 deletions

View 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>

View 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,

View 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>
}

View 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>

View 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;
}
}

View 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()
// }
// }
}
}

View File

@ -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: {

View 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)
})
}
}

View 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`)
}
}

View File

@ -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,

View File

@ -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",
]