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>
|
||||
<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 small">
|
||||
@for (message of messages(); track message) {
|
||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
|
||||
<div class="p-2 m-2" [class.bg-body]="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 ngbDropdownMenu class="dropdown-menu-end shadow p-0 overflow-hidden" aria-labelledby="chatDropdown">
|
||||
<div class="d-flex align-items-center border-bottom px-3 py-2">
|
||||
<i-bs width="1em" height="1em" name="stars" class="text-primary me-2"></i-bs>
|
||||
<span class="fw-semibold small">
|
||||
@if (documentId()) {
|
||||
<ng-container i18n>Ask about this document</ng-container>
|
||||
} @else {
|
||||
<ng-container i18n>Ask about your documents</ng-container>
|
||||
}
|
||||
<div #scrollAnchor></div>
|
||||
</div>
|
||||
</span>
|
||||
@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">
|
||||
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
#chatInput
|
||||
id="chatInput"
|
||||
class="form-control form-control-sm" name="chatInput" type="text"
|
||||
<div class="chat-messages small px-2">
|
||||
@if (!messages().length) {
|
||||
<p class="text-muted text-center py-4 px-3 mb-0" i18n>Answers come from your documents, with links to their sources.</p>
|
||||
}
|
||||
@for (message of messages(); track message) {
|
||||
<div class="message d-flex flex-row" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
|
||||
<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"
|
||||
[disabled]="loading()"
|
||||
[ngModel]="input()"
|
||||
(ngModelChange)="input.set($event)"
|
||||
(keydown)="searchInputKeyDown($event)"
|
||||
/>
|
||||
@if (loading()) {
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="stopStreaming()" i18n>Stop</button>
|
||||
} @else {
|
||||
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()">Send</button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
/>
|
||||
@if (loading()) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="x-lg"></i-bs>
|
||||
</button>
|
||||
} @else {
|
||||
<button class="btn btn-primary" type="button" (click)="sendMessage()" title="Send" i18n-title aria-label="Send" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="arrow-return-left"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
@@ -206,7 +206,7 @@ describe('ChatComponent', () => {
|
||||
component.sendMessage()
|
||||
fixture.detectChanges()
|
||||
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
|
||||
expect(stopButton.textContent).toContain('Stop')
|
||||
expect(stopButton.getAttribute('aria-label')).toBe('Stop')
|
||||
|
||||
mockStream$.next('Hi there')
|
||||
jest.advanceTimersByTime(20)
|
||||
@@ -232,6 +232,27 @@ describe('ChatComponent', () => {
|
||||
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', () => {
|
||||
const message = { content: '', role: 'assistant', isStreaming: true }
|
||||
component.enqueueTypewriter(null, message as any) // coverage for null
|
||||
|
||||
@@ -150,6 +150,12 @@ export class ChatComponent implements OnInit {
|
||||
this.finishStreaming(message)
|
||||
}
|
||||
|
||||
clearChat(): void {
|
||||
this.stopStreaming()
|
||||
this.messages.set([])
|
||||
setTimeout(() => this.chatInput?.nativeElement.focus())
|
||||
}
|
||||
|
||||
private finishStreaming(message: ChatMessage): void {
|
||||
this.clearSlowResponse()
|
||||
this.streamFinished = false
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
arrowDownUp,
|
||||
arrowLeft,
|
||||
arrowRepeat,
|
||||
arrowReturnLeft,
|
||||
arrowRight,
|
||||
arrowRightShort,
|
||||
arrowUpRight,
|
||||
@@ -283,6 +284,7 @@ const icons = {
|
||||
arrowDownUp,
|
||||
arrowLeft,
|
||||
arrowRepeat,
|
||||
arrowReturnLeft,
|
||||
arrowRight,
|
||||
arrowRightShort,
|
||||
arrowUpRight,
|
||||
|
||||
Reference in new issue
Block a user