mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-13 11:37:13 +00:00
More ux love
This commit is contained in:
4 files changed
+102
-52
No files matched your search
@@ -3,63 +3,84 @@
|
|||||||
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-0 overflow-hidden" aria-labelledby="chatDropdown">
|
||||||
<div class="chat-container bg-light p-2">
|
<div class="d-flex align-items-center border-bottom px-3 py-2">
|
||||||
<div class="chat-messages small">
|
<i-bs width="1em" height="1em" name="stars" class="text-primary me-2"></i-bs>
|
||||||
@for (message of messages(); track message) {
|
<span class="fw-semibold small">
|
||||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
|
@if (documentId()) {
|
||||||
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
|
<ng-container i18n>Ask about this document</ng-container>
|
||||||
@if (message.isStreaming && !message.content) {
|
} @else {
|
||||||
<div class="d-flex align-items-center text-muted">
|
<ng-container i18n>Ask about your documents</ng-container>
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
|
||||||
@if (slowResponse()) {
|
|
||||||
<ng-container i18n>Still thinking...</ng-container>
|
|
||||||
} @else {
|
|
||||||
<ng-container i18n>Thinking...</ng-container>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else if (message.role === 'assistant') {
|
|
||||||
<div class="chat-markdown text-break text-wrap" [innerHTML]="message.content | markdown"></div>
|
|
||||||
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
|
||||||
} @else {
|
|
||||||
<span class="text-break">{{ message.content }}</span>
|
|
||||||
}
|
|
||||||
@if (message.role === 'assistant' && message.references?.length) {
|
|
||||||
<div class="chat-references list-group mt-3">
|
|
||||||
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
|
|
||||||
@for (reference of message.references; track reference.id) {
|
|
||||||
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
|
||||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
<div #scrollAnchor></div>
|
</span>
|
||||||
</div>
|
@if (messages().length) {
|
||||||
|
<button class="btn btn-sm btn-link text-muted ms-auto p-0" type="button" (click)="clearChat()" title="Clear chat" i18n-title>
|
||||||
|
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form class="chat-input">
|
<div class="chat-messages small px-2">
|
||||||
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
@if (!messages().length) {
|
||||||
<div class="input-group">
|
<p class="text-muted text-center py-4 px-3 mb-0" i18n>Answers come from your documents, with links to their sources.</p>
|
||||||
<input
|
}
|
||||||
#chatInput
|
@for (message of messages(); track message) {
|
||||||
id="chatInput"
|
<div class="message d-flex flex-row" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
|
||||||
class="form-control form-control-sm" name="chatInput" type="text"
|
<div class="p-2 m-2" [class.bg-body-secondary]="message.role === 'user'" [class.rounded-3]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
|
||||||
|
@if (message.isStreaming && !message.content) {
|
||||||
|
<div class="d-flex align-items-center text-muted">
|
||||||
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
|
@if (slowResponse()) {
|
||||||
|
<ng-container i18n>Still thinking...</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n>Thinking...</ng-container>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (message.role === 'assistant') {
|
||||||
|
<div class="chat-markdown text-break text-wrap" [innerHTML]="message.content | markdown"></div>
|
||||||
|
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
||||||
|
} @else {
|
||||||
|
<span class="text-break">{{ message.content }}</span>
|
||||||
|
}
|
||||||
|
@if (message.role === 'assistant' && message.references?.length) {
|
||||||
|
<div class="chat-references list-group mt-3">
|
||||||
|
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
|
||||||
|
@for (reference of message.references; track reference.id) {
|
||||||
|
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
||||||
|
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div #scrollAnchor></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="chat-input border-top p-2">
|
||||||
|
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
#chatInput
|
||||||
|
id="chatInput"
|
||||||
|
class="form-control" name="chatInput" type="text"
|
||||||
[placeholder]="placeholder"
|
[placeholder]="placeholder"
|
||||||
[disabled]="loading()"
|
[disabled]="loading()"
|
||||||
[ngModel]="input()"
|
[ngModel]="input()"
|
||||||
(ngModelChange)="input.set($event)"
|
(ngModelChange)="input.set($event)"
|
||||||
(keydown)="searchInputKeyDown($event)"
|
(keydown)="searchInputKeyDown($event)"
|
||||||
/>
|
/>
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="stopStreaming()" i18n>Stop</button>
|
<button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
|
||||||
} @else {
|
<i-bs width="1em" height="1em" name="x-lg"></i-bs>
|
||||||
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()">Send</button>
|
</button>
|
||||||
}
|
} @else {
|
||||||
</div>
|
<button class="btn btn-primary" type="button" (click)="sendMessage()" title="Send" i18n-title aria-label="Send" i18n-aria-label>
|
||||||
</form>
|
<i-bs width="1em" height="1em" name="arrow-return-left"></i-bs>
|
||||||
</div>
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -206,7 +206,7 @@ describe('ChatComponent', () => {
|
|||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
|
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
|
||||||
expect(stopButton.textContent).toContain('Stop')
|
expect(stopButton.getAttribute('aria-label')).toBe('Stop')
|
||||||
|
|
||||||
mockStream$.next('Hi there')
|
mockStream$.next('Hi there')
|
||||||
jest.advanceTimersByTime(20)
|
jest.advanceTimersByTime(20)
|
||||||
@@ -232,6 +232,27 @@ describe('ChatComponent', () => {
|
|||||||
expect(component.messages()).toHaveLength(1)
|
expect(component.messages()).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should clear the conversation', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
mockStream$.next('Hi there')
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('button[title="Clear chat"]').click()
|
||||||
|
expect(component.messages()).toHaveLength(0)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
expect(mockStream$.observed).toBe(false)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
jest.advanceTimersByTime(10)
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
fixture.nativeElement.querySelector('#chatInput')
|
||||||
|
)
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'Answers come from your documents'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('should enqueue typewriter chunks correctly', () => {
|
it('should enqueue typewriter chunks correctly', () => {
|
||||||
const message = { content: '', role: 'assistant', isStreaming: true }
|
const message = { content: '', role: 'assistant', isStreaming: true }
|
||||||
component.enqueueTypewriter(null, message as any) // coverage for null
|
component.enqueueTypewriter(null, message as any) // coverage for null
|
||||||
|
|||||||
@@ -150,6 +150,12 @@ export class ChatComponent implements OnInit {
|
|||||||
this.finishStreaming(message)
|
this.finishStreaming(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearChat(): void {
|
||||||
|
this.stopStreaming()
|
||||||
|
this.messages.set([])
|
||||||
|
setTimeout(() => this.chatInput?.nativeElement.focus())
|
||||||
|
}
|
||||||
|
|
||||||
private finishStreaming(message: ChatMessage): void {
|
private finishStreaming(message: ChatMessage): void {
|
||||||
this.clearSlowResponse()
|
this.clearSlowResponse()
|
||||||
this.streamFinished = false
|
this.streamFinished = false
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
arrowDownUp,
|
arrowDownUp,
|
||||||
arrowLeft,
|
arrowLeft,
|
||||||
arrowRepeat,
|
arrowRepeat,
|
||||||
|
arrowReturnLeft,
|
||||||
arrowRight,
|
arrowRight,
|
||||||
arrowRightShort,
|
arrowRightShort,
|
||||||
arrowUpRight,
|
arrowUpRight,
|
||||||
@@ -283,6 +284,7 @@ const icons = {
|
|||||||
arrowDownUp,
|
arrowDownUp,
|
||||||
arrowLeft,
|
arrowLeft,
|
||||||
arrowRepeat,
|
arrowRepeat,
|
||||||
|
arrowReturnLeft,
|
||||||
arrowRight,
|
arrowRight,
|
||||||
arrowRightShort,
|
arrowRightShort,
|
||||||
arrowUpRight,
|
arrowUpRight,
|
||||||
|
|||||||
Reference in new issue
Block a user