- @for (message of messages; track message) {
+ @for (message of messages(); track message) {
@@ -36,11 +36,12 @@
id="chatInput"
class="form-control form-control-sm" name="chatInput" type="text"
[placeholder]="placeholder"
- [disabled]="loading"
- [(ngModel)]="input"
+ [disabled]="loading()"
+ [ngModel]="input()"
+ (ngModelChange)="input.set($event)"
(keydown)="searchInputKeyDown($event)"
/>
-
+
diff --git a/src-ui/src/app/components/chat/chat/chat.component.spec.ts b/src-ui/src/app/components/chat/chat/chat.component.spec.ts
index c32e4f9d5..5b8e12af8 100644
--- a/src-ui/src/app/components/chat/chat/chat.component.spec.ts
+++ b/src-ui/src/app/components/chat/chat/chat.component.spec.ts
@@ -56,44 +56,53 @@ describe('ChatComponent', () => {
it('should update documentId on initialization', () => {
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
component.ngOnInit()
- expect(component.documentId).toBe(123)
+ expect(component.documentId()).toBe(123)
})
it('should update documentId on navigation', () => {
component.ngOnInit()
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
- expect(component.documentId).toBe(456)
+ expect(component.documentId()).toBe(456)
})
it('should return correct placeholder based on documentId', () => {
- component.documentId = 123
+ component.documentId.set(123)
expect(component.placeholder).toBe('Ask a question about this document...')
- component.documentId = undefined
+ component.documentId.set(undefined)
expect(component.placeholder).toBe('Ask a question about a document...')
})
- it('should send a message and handle streaming response', () => {
- component.input = 'Hello'
+ it('should send a message and render the streaming response', async () => {
+ component.input.set('Hello')
component.sendMessage()
- expect(component.messages).toHaveLength(2)
- expect(component.messages[0].content).toBe('Hello')
- expect(component.loading).toBe(true)
+ expect(component.messages()).toHaveLength(2)
+ expect(component.messages()[0].content).toBe('Hello')
+ expect(component.loading()).toBe(true)
mockStream$.next('Hi')
- expect(component.messages[1].content).toBe('H')
+ expect(component.messages()[1].content).toBe('H')
mockStream$.next('Hi there')
// advance time to process the typewriter effect
- jest.advanceTimersByTime(1000)
- expect(component.messages[1].content).toBe('Hi there')
+ await jest.runAllTimersAsync()
+ await fixture.whenStable()
+ expect(component.messages()[1].content).toBe('Hi there')
+ expect(
+ fixture.nativeElement.querySelector('.chat-messages').textContent
+ ).toContain('Hi there')
mockStream$.complete()
- expect(component.loading).toBe(false)
- expect(component.messages[1].isStreaming).toBe(false)
+ await jest.runAllTimersAsync()
+ await fixture.whenStable()
+ expect(component.loading()).toBe(false)
+ expect(component.messages()[1].isStreaming).toBe(false)
+ expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
+ false
+ )
})
it('should parse references from the metadata trailer without showing it', () => {
- component.input = 'Hello'
+ component.input.set('Hello')
component.sendMessage()
mockStream$.next(
@@ -101,14 +110,14 @@ describe('ChatComponent', () => {
)
jest.advanceTimersByTime(1000)
- expect(component.messages[1].content).toBe('Hi there')
- expect(component.messages[1].references).toEqual([
+ 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.input.set('Hello')
component.sendMessage()
mockStream$.next(
@@ -123,12 +132,12 @@ describe('ChatComponent', () => {
})
it('should remove delimiter fragments that were already streamed', () => {
- component.input = 'Hello'
+ component.input.set('Hello')
component.sendMessage()
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
jest.advanceTimersByTime(1000)
- expect(component.messages[1].content).toBe(
+ expect(component.messages()[1].content).toBe(
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
)
@@ -137,21 +146,21 @@ describe('ChatComponent', () => {
)
jest.advanceTimersByTime(1000)
- expect(component.messages[1].content).toBe('Hi there')
- expect(component.messages[1].references).toEqual([
+ 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.input.set('Hello')
component.sendMessage()
mockStream$.error('Error')
- expect(component.messages[1].content).toContain(
+ expect(component.messages()[1].content).toContain(
'⚠️ Error receiving response.'
)
- expect(component.loading).toBe(false)
+ expect(component.loading()).toBe(false)
})
it('should enqueue typewriter chunks correctly', () => {
@@ -166,7 +175,7 @@ describe('ChatComponent', () => {
ChatComponent.prototype as any,
'scrollToBottom'
)
- component.input = 'Test'
+ component.input.set('Test')
component.sendMessage()
expect(scrollSpy).toHaveBeenCalled()
})
diff --git a/src-ui/src/app/components/chat/chat/chat.component.ts b/src-ui/src/app/components/chat/chat/chat.component.ts
index 0920af027..4525808a4 100644
--- a/src-ui/src/app/components/chat/chat/chat.component.ts
+++ b/src-ui/src/app/components/chat/chat/chat.component.ts
@@ -1,4 +1,11 @@
-import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
+import {
+ Component,
+ ElementRef,
+ inject,
+ OnInit,
+ signal,
+ ViewChild,
+} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@@ -23,10 +30,10 @@ import {
styleUrl: './chat.component.scss',
})
export class ChatComponent implements OnInit {
- public messages: ChatMessage[] = []
- public loading = false
- public input: string = ''
- public documentId!: number
+ readonly messages = signal
([])
+ readonly loading = signal(false)
+ readonly input = signal('')
+ readonly documentId = signal(undefined)
private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router)
@@ -38,7 +45,7 @@ export class ChatComponent implements OnInit {
private typewriterActive = false
public get placeholder(): string {
- return this.documentId
+ return this.documentId()
? $localize`Ask a question about this document...`
: $localize`Ask a question about a document...`
}
@@ -57,14 +64,14 @@ export class ChatComponent implements OnInit {
private updateDocumentId(url: string): void {
const docIdRe = url.match(/^\/documents\/(\d+)/)
- this.documentId = docIdRe ? +docIdRe[1] : undefined
+ this.documentId.set(docIdRe ? +docIdRe[1] : undefined)
}
sendMessage(): void {
- if (!this.input.trim()) return
+ if (!this.input().trim()) return
- const userMessage: ChatMessage = { role: 'user', content: this.input }
- this.messages.push(userMessage)
+ const userMessage: ChatMessage = { role: 'user', content: this.input() }
+ this.messages.update((messages) => [...messages, userMessage])
this.scrollToBottom()
const assistantMessage: ChatMessage = {
@@ -72,12 +79,12 @@ export class ChatComponent implements OnInit {
content: '',
isStreaming: true,
}
- this.messages.push(assistantMessage)
- this.loading = true
+ this.messages.update((messages) => [...messages, assistantMessage])
+ this.loading.set(true)
let lastVisibleContent = ''
- this.chatService.streamChat(this.documentId, this.input).subscribe({
+ this.chatService.streamChat(this.documentId(), this.input()).subscribe({
next: (chunk) => {
const nextResponse = parseChatResponse(chunk)
@@ -93,26 +100,30 @@ export class ChatComponent implements OnInit {
}
assistantMessage.references = nextResponse.references
+ this.notifyMessagesChanged()
},
error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.'
assistantMessage.isStreaming = false
- this.loading = false
+ this.notifyMessagesChanged()
+ this.loading.set(false)
},
complete: () => {
assistantMessage.isStreaming = false
- this.loading = false
+ this.notifyMessagesChanged()
+ this.loading.set(false)
this.scrollToBottom()
},
})
- this.input = ''
+ this.input.set('')
}
private resetTypewriter(message: ChatMessage, content: string): void {
this.typewriterBuffer = []
this.typewriterActive = false
message.content = content
+ this.notifyMessagesChanged()
this.scrollToBottom()
}
@@ -135,11 +146,16 @@ export class ChatComponent implements OnInit {
const nextChar = this.typewriterBuffer.shift()
message.content += nextChar
+ this.notifyMessagesChanged()
this.scrollToBottom()
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
}
+ private notifyMessagesChanged(): void {
+ this.messages.update((messages) => [...messages])
+ }
+
private scrollToBottom(): void {
setTimeout(() => {
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })