<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 }}</span>
+ <span class="p-2 m-2" [class.bg-dark]="message.role === 'user'">
+ {{ message.content }}
+ @if (message.isStreaming) { <span class="blinking-cursor">|</span> }
+ </span>
</div>
}
<div #scrollAnchor></div>
-import { NgClass } from '@angular/common'
import { Component, ElementRef, ViewChild } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
ReactiveFormsModule,
NgxBootstrapIconsModule,
NgbDropdownModule,
- NgClass,
],
templateUrl: './chat.component.html',
styleUrl: './chat.component.scss',
@ViewChild('scrollAnchor') scrollAnchor!: ElementRef<HTMLDivElement>
@ViewChild('inputField') inputField!: ElementRef<HTMLInputElement>
+ private typewriterBuffer: string[] = []
+ private typewriterActive = false
+
constructor(private chatService: ChatService) {}
sendMessage(): void {
this.chatService.streamChat(this.documentId, this.input).subscribe({
next: (chunk) => {
- assistantMessage.content += chunk.substring(lastPartialLength)
+ const delta = chunk.substring(lastPartialLength)
lastPartialLength = chunk.length
- this.scrollToBottom()
+ this.enqueueTypewriter(delta, assistantMessage)
},
error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.'
this.input = ''
}
- scrollToBottom(): void {
+ enqueueTypewriter(chunk: string, message: ChatMessage): void {
+ if (!chunk) return
+
+ this.typewriterBuffer.push(...chunk.split(''))
+
+ if (!this.typewriterActive) {
+ this.typewriterActive = true
+ this.playTypewriter(message)
+ }
+ }
+
+ playTypewriter(message: ChatMessage): void {
+ if (this.typewriterBuffer.length === 0) {
+ this.typewriterActive = false
+ return
+ }
+
+ const nextChar = this.typewriterBuffer.shift()!
+ message.content += nextChar
+ this.scrollToBottom()
+
+ setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
+ }
+
+ private scrollToBottom(): void {
setTimeout(() => {
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
}, 50)
}
- onOpenChange(open: boolean): void {
+ public onOpenChange(open: boolean): void {
if (open) {
setTimeout(() => {
this.inputField.nativeElement.focus()