Sweet chat animation, cursor

This commit is contained in:
shamoon 2025-04-25 23:08:20 -07:00
parent 9c3249f1f1
commit 348d175d42
No known key found for this signature in database
3 changed files with 50 additions and 7 deletions

View File

@ -8,7 +8,10 @@
<div class="chat-messages font-monospace small"> <div class="chat-messages font-monospace small">
@for (message of messages; track message) { @for (message of messages; track message) {
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'"> <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>
} }
<div #scrollAnchor></div> <div #scrollAnchor></div>

View File

@ -20,3 +20,18 @@
right: -3rem; right: -3rem;
} }
} }
.blinking-cursor {
font-weight: bold;
font-size: 1.2em;
animation: blink 1s step-end infinite;
}
@keyframes blink {
from, to {
opacity: 0;
}
50% {
opacity: 1;
}
}

View File

@ -1,4 +1,3 @@
import { NgClass } from '@angular/common'
import { Component, ElementRef, ViewChild } from '@angular/core' import { Component, ElementRef, ViewChild } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@ -12,7 +11,6 @@ import { ChatMessage, ChatService } from 'src/app/services/chat.service'
ReactiveFormsModule, ReactiveFormsModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
NgbDropdownModule, NgbDropdownModule,
NgClass,
], ],
templateUrl: './chat.component.html', templateUrl: './chat.component.html',
styleUrl: './chat.component.scss', styleUrl: './chat.component.scss',
@ -25,6 +23,9 @@ export class ChatComponent {
@ViewChild('scrollAnchor') scrollAnchor!: ElementRef<HTMLDivElement> @ViewChild('scrollAnchor') scrollAnchor!: ElementRef<HTMLDivElement>
@ViewChild('inputField') inputField!: ElementRef<HTMLInputElement> @ViewChild('inputField') inputField!: ElementRef<HTMLInputElement>
private typewriterBuffer: string[] = []
private typewriterActive = false
constructor(private chatService: ChatService) {} constructor(private chatService: ChatService) {}
sendMessage(): void { sendMessage(): void {
@ -45,9 +46,9 @@ export class ChatComponent {
this.chatService.streamChat(this.documentId, this.input).subscribe({ this.chatService.streamChat(this.documentId, this.input).subscribe({
next: (chunk) => { next: (chunk) => {
assistantMessage.content += chunk.substring(lastPartialLength) const delta = chunk.substring(lastPartialLength)
lastPartialLength = chunk.length lastPartialLength = chunk.length
this.scrollToBottom() this.enqueueTypewriter(delta, assistantMessage)
}, },
error: () => { error: () => {
assistantMessage.content += '\n\n⚠ Error receiving response.' assistantMessage.content += '\n\n⚠ Error receiving response.'
@ -64,13 +65,37 @@ export class ChatComponent {
this.input = '' 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(() => { setTimeout(() => {
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' }) this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
}, 50) }, 50)
} }
onOpenChange(open: boolean): void { public onOpenChange(open: boolean): void {
if (open) { if (open) {
setTimeout(() => { setTimeout(() => {
this.inputField.nativeElement.focus() this.inputField.nativeElement.focus()