mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-03 17:42:20 +00:00
Enhancement: chat message document links (#12670)
This commit is contained in:
@@ -8,10 +8,21 @@
|
||||
<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 }}
|
||||
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
||||
</span>
|
||||
<div class="p-2 m-2" [class.bg-dark]="message.role === 'user'">
|
||||
<span>
|
||||
{{ message.content }}
|
||||
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
||||
</span>
|
||||
@if (message.role === 'assistant' && message.references?.length) {
|
||||
<div class="chat-references list-group mt-3">
|
||||
@for (reference of message.references; track reference.id) {
|
||||
<a class="list-group-item list-group-item-action text-primary" [routerLink]="['/documents', reference.id]">
|
||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div #scrollAnchor></div>
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-references {
|
||||
font-family: var(--bs-font-sans-serif);
|
||||
}
|
||||
|
||||
.dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ElementRef } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NavigationEnd, Router } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subject } from 'rxjs'
|
||||
import { ChatService } from 'src/app/services/chat.service'
|
||||
import {
|
||||
CHAT_METADATA_DELIMITER,
|
||||
ChatService,
|
||||
} from 'src/app/services/chat.service'
|
||||
import { ChatComponent } from './chat.component'
|
||||
|
||||
describe('ChatComponent', () => {
|
||||
@@ -18,7 +22,11 @@ describe('ChatComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [NgxBootstrapIconsModule.pick(allIcons), ChatComponent],
|
||||
imports: [
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
RouterTestingModule,
|
||||
ChatComponent,
|
||||
],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
@@ -84,6 +92,57 @@ describe('ChatComponent', () => {
|
||||
expect(component.messages[1].isStreaming).toBe(false)
|
||||
})
|
||||
|
||||
it('should parse references from the metadata trailer without showing it', () => {
|
||||
component.input = 'Hello'
|
||||
component.sendMessage()
|
||||
|
||||
mockStream$.next(
|
||||
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
|
||||
)
|
||||
jest.advanceTimersByTime(1000)
|
||||
|
||||
expect(component.messages[1].content).toBe('Hi there')
|
||||
expect(component.messages[1].references).toEqual([
|
||||
{ id: 42, title: 'Bread Recipe' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should render document reference links under assistant messages', () => {
|
||||
component.input = 'Hello'
|
||||
component.sendMessage()
|
||||
|
||||
mockStream$.next(
|
||||
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
|
||||
)
|
||||
jest.advanceTimersByTime(1000)
|
||||
fixture.detectChanges()
|
||||
|
||||
const link = fixture.nativeElement.querySelector('.chat-references a')
|
||||
expect(link.textContent).toContain('Bread Recipe')
|
||||
expect(link.getAttribute('href')).toContain('/documents/42')
|
||||
})
|
||||
|
||||
it('should remove delimiter fragments that were already streamed', () => {
|
||||
component.input = 'Hello'
|
||||
component.sendMessage()
|
||||
|
||||
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
|
||||
jest.advanceTimersByTime(1000)
|
||||
expect(component.messages[1].content).toBe(
|
||||
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
|
||||
)
|
||||
|
||||
mockStream$.next(
|
||||
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
|
||||
)
|
||||
jest.advanceTimersByTime(1000)
|
||||
|
||||
expect(component.messages[1].content).toBe('Hi there')
|
||||
expect(component.messages[1].references).toEqual([
|
||||
{ id: 42, title: 'Bread Recipe' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle errors during streaming', () => {
|
||||
component.input = 'Hello'
|
||||
component.sendMessage()
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NavigationEnd, Router } from '@angular/router'
|
||||
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { filter, map } from 'rxjs'
|
||||
import { ChatMessage, ChatService } from 'src/app/services/chat.service'
|
||||
import {
|
||||
ChatMessage,
|
||||
ChatService,
|
||||
parseChatResponse,
|
||||
} from 'src/app/services/chat.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-chat',
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
NgxBootstrapIconsModule,
|
||||
NgbDropdownModule,
|
||||
],
|
||||
@@ -70,13 +75,24 @@ export class ChatComponent implements OnInit {
|
||||
this.messages.push(assistantMessage)
|
||||
this.loading = true
|
||||
|
||||
let lastPartialLength = 0
|
||||
let lastVisibleContent = ''
|
||||
|
||||
this.chatService.streamChat(this.documentId, this.input).subscribe({
|
||||
next: (chunk) => {
|
||||
const delta = chunk.substring(lastPartialLength)
|
||||
lastPartialLength = chunk.length
|
||||
this.enqueueTypewriter(delta, assistantMessage)
|
||||
const nextResponse = parseChatResponse(chunk)
|
||||
|
||||
if (nextResponse.content.length < lastVisibleContent.length) {
|
||||
this.resetTypewriter(assistantMessage, nextResponse.content)
|
||||
lastVisibleContent = nextResponse.content
|
||||
} else {
|
||||
const visibleDelta = nextResponse.content.substring(
|
||||
lastVisibleContent.length
|
||||
)
|
||||
lastVisibleContent = nextResponse.content
|
||||
this.enqueueTypewriter(visibleDelta, assistantMessage)
|
||||
}
|
||||
|
||||
assistantMessage.references = nextResponse.references
|
||||
},
|
||||
error: () => {
|
||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||
@@ -93,6 +109,13 @@ export class ChatComponent implements OnInit {
|
||||
this.input = ''
|
||||
}
|
||||
|
||||
private resetTypewriter(message: ChatMessage, content: string): void {
|
||||
this.typewriterBuffer = []
|
||||
this.typewriterActive = false
|
||||
message.content = content
|
||||
this.scrollToBottom()
|
||||
}
|
||||
|
||||
enqueueTypewriter(chunk: string, message: ChatMessage): void {
|
||||
if (!chunk) return
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
} from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ChatService } from './chat.service'
|
||||
import {
|
||||
CHAT_METADATA_DELIMITER,
|
||||
ChatService,
|
||||
parseChatResponse,
|
||||
} from './chat.service'
|
||||
|
||||
describe('ChatService', () => {
|
||||
let service: ChatService
|
||||
@@ -55,4 +59,22 @@ describe('ChatService', () => {
|
||||
partialText: mockResponse,
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('should parse chat references from the metadata trailer', () => {
|
||||
const parsed = parseChatResponse(
|
||||
`Answer text${CHAT_METADATA_DELIMITER}{"references":[{"id":1,"title":"Document 1"}]}`
|
||||
)
|
||||
|
||||
expect(parsed.content).toBe('Answer text')
|
||||
expect(parsed.references).toEqual([{ id: 1, title: 'Document 1' }])
|
||||
})
|
||||
|
||||
it('should hide incomplete metadata trailer from the visible content', () => {
|
||||
const parsed = parseChatResponse(
|
||||
`Answer text${CHAT_METADATA_DELIMITER}{"references"`
|
||||
)
|
||||
|
||||
expect(parsed.content).toBe('Answer text')
|
||||
expect(parsed.references).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,46 @@ export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
isStreaming?: boolean
|
||||
references?: ChatReference[]
|
||||
}
|
||||
|
||||
export interface ChatReference {
|
||||
id: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface ParsedChatResponse {
|
||||
content: string
|
||||
references?: ChatReference[]
|
||||
}
|
||||
|
||||
export const CHAT_METADATA_DELIMITER = '\n\n__PAPERLESS_CHAT_METADATA__'
|
||||
|
||||
export function parseChatResponse(response: string): ParsedChatResponse {
|
||||
const delimiterIndex = response.indexOf(CHAT_METADATA_DELIMITER)
|
||||
|
||||
if (delimiterIndex === -1) {
|
||||
return { content: response }
|
||||
}
|
||||
|
||||
const metadataString = response.slice(
|
||||
delimiterIndex + CHAT_METADATA_DELIMITER.length
|
||||
)
|
||||
|
||||
try {
|
||||
const metadata = JSON.parse(metadataString) as {
|
||||
references?: ChatReference[]
|
||||
}
|
||||
|
||||
return {
|
||||
content: response.slice(0, delimiterIndex),
|
||||
references: metadata.references ?? [],
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
content: response.slice(0, delimiterIndex),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
||||
Reference in New Issue
Block a user