]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Sweet chat animation, cursor
authorshamoon <4887959+shamoon@users.noreply.github.com>
Sat, 26 Apr 2025 06:08:20 +0000 (23:08 -0700)
committershamoon <4887959+shamoon@users.noreply.github.com>
Wed, 2 Jul 2025 18:03:56 +0000 (11:03 -0700)
src-ui/src/app/components/chat/chat/chat.component.html
src-ui/src/app/components/chat/chat/chat.component.scss
src-ui/src/app/components/chat/chat/chat.component.ts

index 8a12ed471c0777c6525adb263cc99fdddb3a2a73..54753c88f1056084fb2a86b97be56e97766a9da9 100644 (file)
@@ -8,7 +8,10 @@
       <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>
index 9eb9dadee602788965edc5feaff32daeee1a1f31..4b00cce1be6cad54256a0b1208ca0e04ad225cc0 100644 (file)
     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;
+  }
+}
index 0d17f132e223e8628d6bf0c60809f40278d57d3d..750edd9376b35bf7519c96e0ca15a47b8444e017 100644 (file)
@@ -1,4 +1,3 @@
-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'
@@ -12,7 +11,6 @@ import { ChatMessage, ChatService } from 'src/app/services/chat.service'
     ReactiveFormsModule,
     NgxBootstrapIconsModule,
     NgbDropdownModule,
-    NgClass,
   ],
   templateUrl: './chat.component.html',
   styleUrl: './chat.component.scss',
@@ -25,6 +23,9 @@ export class ChatComponent {
   @ViewChild('scrollAnchor') scrollAnchor!: ElementRef<HTMLDivElement>
   @ViewChild('inputField') inputField!: ElementRef<HTMLInputElement>
 
+  private typewriterBuffer: string[] = []
+  private typewriterActive = false
+
   constructor(private chatService: ChatService) {}
 
   sendMessage(): void {
@@ -45,9 +46,9 @@ export class ChatComponent {
 
     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.'
@@ -64,13 +65,37 @@ export class ChatComponent {
     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()