Compare commits

...
Author SHA1 Message Date
shamoon dfaf270f83 Tweak cursor, make sources appear after its done streaming 2026-10-12 10:54:04 -07:00
shamoon 706504cac0 More ux love 2026-10-12 10:28:29 -07:00
shamoon 8878f7bf1f Add a bit of space between messages 2026-10-12 08:34:51 -07:00
shamoon 5d58193a5d improve sources list 2026-10-12 08:34:50 -07:00
shamoon d479d07e05 Add a rarr 2026-10-12 08:34:50 -07:00
shamoon ce45104cd5 make chat a bit bigger 2026-10-12 08:34:50 -07:00
shamoon ac5d7de9cc fix cursor timing 2026-10-12 08:34:50 -07:00
shamoon 560d1e53ed dont clip errors errors 2026-10-12 08:34:50 -07:00
shamoon 37b3d7240c Add stop button to chat 2026-10-12 08:34:49 -07:00
shamoon 4f6907ba6f Show still thinking for slow responses 2026-10-12 08:34:49 -07:00
shamoon c55f37e980 Better loading indicator 2026-10-12 08:34:49 -07:00
5 changed files with 284 additions and 85 deletions

No files matched your search

@@ -1,51 +1,85 @@
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)"> <li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)">
<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'"> @if (documentId()) {
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'"> <ng-container i18n>Ask about this document</ng-container>
@if (message.role === 'assistant') { } @else {
<div class="chat-markdown text-break text-wrap" [innerHTML]="message.content | markdown"></div> <ng-container i18n>Ask about your documents</ng-container>
@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">
@for (reference of message.references; track reference.id) {
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [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> </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" [class.streaming]="message.isStreaming" [innerHTML]="message.content | markdown"></div>
} @else {
<span class="text-break">{{ message.content }}</span>
}
@if (message.role === 'assistant' && !message.isStreaming && 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)"
/> />
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button> @if (loading()) {
</div> <button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
</form> <i-bs width="1em" height="1em" name="x-lg"></i-bs>
</div> </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> </div>
</li> </li>
@@ -1,9 +1,9 @@
.dropdown-menu { .dropdown-menu {
width: var(--pngx-toast-max-width); width: min(36rem, calc(100vw - 20px));
} }
.chat-messages { .chat-messages {
max-height: 350px; max-height: min(60dvh, 36rem);
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap; white-space: pre-wrap;
} }
@@ -16,24 +16,29 @@
white-space: initial; white-space: initial;
} }
@media screen and (max-width: 400px) { @media screen and (max-width: 575.98px) {
:host ::ng-deep .dropdown-menu-end { .chat-messages {
right: -3rem; max-height: calc(100dvh - 12rem);
} }
} }
.blinking-cursor { .chat-markdown.streaming ::ng-deep {
font-weight: bold; > :last-child:not(ul, ol, table)::after,
font-size: 1.2em; > :is(ul, ol):last-child > li:last-child::after {
animation: blink 1s step-end infinite; content: '';
display: inline-block;
width: 2px;
height: 1.05em;
margin-left: 2px;
background-color: currentColor;
vertical-align: -0.15em;
animation: blink 1s step-end infinite;
}
} }
@keyframes blink { @keyframes blink {
from, to {
opacity: 0;
}
50% { 50% {
opacity: 1; opacity: 0;
} }
} }
@@ -80,8 +80,16 @@ describe('ChatComponent', () => {
expect(component.messages()[0].content).toBe('Hello') expect(component.messages()[0].content).toBe('Hello')
expect(component.loading()).toBe(true) expect(component.loading()).toBe(true)
fixture.detectChanges()
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
expect(messagesEl.querySelector('.spinner-border')).not.toBeNull()
expect(messagesEl.textContent).toContain('Thinking...')
mockStream$.next('Hi') mockStream$.next('Hi')
expect(component.messages()[1].content).toBe('H') expect(component.messages()[1].content).toBe('H')
fixture.detectChanges()
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
expect(messagesEl.querySelector('.chat-markdown.streaming')).not.toBeNull()
mockStream$.next('Hi there') mockStream$.next('Hi there')
// advance time to process the typewriter effect // advance time to process the typewriter effect
await jest.runAllTimersAsync() await jest.runAllTimersAsync()
@@ -96,6 +104,8 @@ describe('ChatComponent', () => {
await fixture.whenStable() await fixture.whenStable()
expect(component.loading()).toBe(false) expect(component.loading()).toBe(false)
expect(component.messages()[1].isStreaming).toBe(false) expect(component.messages()[1].isStreaming).toBe(false)
fixture.detectChanges()
expect(messagesEl.querySelector('.chat-markdown.streaming')).toBeNull()
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe( expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
false false
) )
@@ -123,6 +133,10 @@ describe('ChatComponent', () => {
mockStream$.next( mockStream$.next(
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}` `Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
) )
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('.chat-references')).toBeNull()
mockStream$.complete()
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
fixture.detectChanges() fixture.detectChanges()
@@ -156,13 +170,96 @@ describe('ChatComponent', () => {
component.input.set('Hello') component.input.set('Hello')
component.sendMessage() component.sendMessage()
mockStream$.next('Partial')
mockStream$.error('Error') mockStream$.error('Error')
expect(component.messages()[1].content).toContain( expect(component.messages()[1].content).toBe(
'⚠️ Error receiving response.' 'Partial\n\n⚠️ Error receiving response.'
) )
expect(component.loading()).toBe(false) expect(component.loading()).toBe(false)
}) })
it('should keep streaming until the typewriter catches up', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Hi there')
mockStream$.complete()
expect(component.messages()[1].isStreaming).toBe(true)
expect(component.loading()).toBe(true)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
})
it('should show a slow response message while waiting', () => {
component.input.set('Hello')
component.sendMessage()
fixture.detectChanges()
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
expect(messagesEl.textContent).not.toContain('Still thinking...')
jest.advanceTimersByTime(8000)
fixture.detectChanges()
expect(messagesEl.textContent).toContain('Still thinking...')
mockStream$.next('Hi')
expect(component.slowResponse()).toBe(false)
})
it('should stop streaming and keep the partial response', () => {
component.input.set('Hello')
component.sendMessage()
fixture.detectChanges()
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
expect(stopButton.getAttribute('aria-label')).toBe('Stop')
mockStream$.next('Hi there')
jest.advanceTimersByTime(20)
stopButton.click()
expect(mockStream$.observed).toBe(false)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
})
it('should drop the empty response when stopped before any content', () => {
component.input.set('Hello')
component.sendMessage()
component.stopStreaming()
expect(component.messages()).toHaveLength(1)
expect(component.loading()).toBe(false)
component.stopStreaming() // no-op once stopped
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
@@ -10,13 +10,16 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router' import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { filter, map, Subscription } from 'rxjs'
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe' import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
import { filter, map } from 'rxjs'
import { import {
ChatMessage, ChatMessage,
ChatService, ChatService,
parseChatResponse, parseChatResponse,
} from 'src/app/services/chat.service' } from 'src/app/services/chat.service'
import { pngxPopperOptions } from 'src/app/utils/popper-options'
const SLOW_RESPONSE_DELAY_MS = 8000
@Component({ @Component({
selector: 'pngx-chat', selector: 'pngx-chat',
@@ -36,6 +39,8 @@ export class ChatComponent implements OnInit {
readonly loading = signal(false) readonly loading = signal(false)
readonly input = signal('') readonly input = signal('')
readonly documentId = signal<number>(undefined) readonly documentId = signal<number>(undefined)
readonly slowResponse = signal(false)
public popperOptions = pngxPopperOptions
private chatService: ChatService = inject(ChatService) private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router) private router: Router = inject(Router)
@@ -45,6 +50,11 @@ export class ChatComponent implements OnInit {
private typewriterBuffer: string[] = [] private typewriterBuffer: string[] = []
private typewriterActive = false private typewriterActive = false
private typewriterTimeout: ReturnType<typeof setTimeout>
private streamSubscription: Subscription
private streamingMessage: ChatMessage
private streamFinished = false
private slowResponseTimeout: ReturnType<typeof setTimeout>
public get placeholder(): string { public get placeholder(): string {
return this.documentId() return this.documentId()
@@ -83,47 +93,96 @@ export class ChatComponent implements OnInit {
} }
this.messages.update((messages) => [...messages, assistantMessage]) this.messages.update((messages) => [...messages, assistantMessage])
this.loading.set(true) this.loading.set(true)
this.streamingMessage = assistantMessage
this.streamFinished = false
this.slowResponseTimeout = setTimeout(
() => this.slowResponse.set(true),
SLOW_RESPONSE_DELAY_MS
)
let lastVisibleContent = '' let lastVisibleContent = ''
this.chatService.streamChat(this.documentId(), this.input()).subscribe({ this.streamSubscription = this.chatService
next: (chunk) => { .streamChat(this.documentId(), this.input())
const nextResponse = parseChatResponse(chunk) .subscribe({
next: (chunk) => {
this.clearSlowResponse()
const nextResponse = parseChatResponse(chunk)
if (nextResponse.content.length < lastVisibleContent.length) { if (nextResponse.content.length < lastVisibleContent.length) {
this.resetTypewriter(assistantMessage, nextResponse.content) this.resetTypewriter(assistantMessage, nextResponse.content)
lastVisibleContent = nextResponse.content lastVisibleContent = nextResponse.content
} else { } else {
const visibleDelta = nextResponse.content.substring( const visibleDelta = nextResponse.content.substring(
lastVisibleContent.length lastVisibleContent.length
) )
lastVisibleContent = nextResponse.content lastVisibleContent = nextResponse.content
this.enqueueTypewriter(visibleDelta, assistantMessage) this.enqueueTypewriter(visibleDelta, assistantMessage)
} }
assistantMessage.references = nextResponse.references assistantMessage.references = nextResponse.references
this.notifyMessagesChanged() this.notifyMessagesChanged()
}, },
error: () => { error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.' this.flushTypewriter(assistantMessage)
assistantMessage.isStreaming = false assistantMessage.content += '\n\n⚠️ Error receiving response.'
this.notifyMessagesChanged() this.finishStreaming(assistantMessage)
this.loading.set(false) },
}, complete: () => {
complete: () => { // let the typewriter catch up before hiding the cursor
assistantMessage.isStreaming = false this.streamFinished = true
this.notifyMessagesChanged() if (!this.typewriterActive) this.finishStreaming(assistantMessage)
this.loading.set(false) },
this.scrollToBottom() })
},
})
this.input.set('') this.input.set('')
} }
private resetTypewriter(message: ChatMessage, content: string): void { stopStreaming(): void {
const message = this.streamingMessage
if (!message?.isStreaming) return
this.streamSubscription?.unsubscribe()
this.flushTypewriter(message)
if (!message.content) {
this.messages.update((messages) => messages.filter((m) => m !== message))
}
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
message.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
this.scrollToBottom()
}
private clearSlowResponse(): void {
clearTimeout(this.slowResponseTimeout)
this.slowResponse.set(false)
}
private flushTypewriter(message: ChatMessage): void {
message.content += this.typewriterBuffer.join('')
this.cancelTypewriter()
}
private cancelTypewriter(): void {
clearTimeout(this.typewriterTimeout)
this.typewriterBuffer = [] this.typewriterBuffer = []
this.typewriterActive = false this.typewriterActive = false
}
private resetTypewriter(message: ChatMessage, content: string): void {
this.cancelTypewriter()
message.content = content message.content = content
this.notifyMessagesChanged() this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
@@ -143,6 +202,7 @@ export class ChatComponent implements OnInit {
playTypewriter(message: ChatMessage): void { playTypewriter(message: ChatMessage): void {
if (this.typewriterBuffer.length === 0) { if (this.typewriterBuffer.length === 0) {
this.typewriterActive = false this.typewriterActive = false
if (this.streamFinished) this.finishStreaming(message)
return return
} }
@@ -151,7 +211,8 @@ export class ChatComponent implements OnInit {
this.notifyMessagesChanged() this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character // 10ms per character
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
} }
private notifyMessagesChanged(): void { private notifyMessagesChanged(): void {
+2
View File
@@ -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,