diff --git a/src-ui/src/app/components/chat/chat/chat.component.html b/src-ui/src/app/components/chat/chat/chat.component.html index 51ea30eb2..8b3f90c1c 100644 --- a/src-ui/src/app/components/chat/chat/chat.component.html +++ b/src-ui/src/app/components/chat/chat/chat.component.html @@ -52,7 +52,11 @@ (ngModelChange)="input.set($event)" (keydown)="searchInputKeyDown($event)" /> - + @if (loading()) { + + } @else { + + } 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 df4d942ac..25a119f1a 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 @@ -185,6 +185,37 @@ describe('ChatComponent', () => { 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.textContent).toContain('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 enqueue typewriter chunks correctly', () => { const message = { content: '', role: 'assistant', isStreaming: true } component.enqueueTypewriter(null, message as any) // coverage for null 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 d58068574..0ffcb5d3d 100644 --- a/src-ui/src/app/components/chat/chat/chat.component.ts +++ b/src-ui/src/app/components/chat/chat/chat.component.ts @@ -10,8 +10,8 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { NavigationEnd, Router, RouterModule } from '@angular/router' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { filter, map, Subscription } from 'rxjs' import { MarkdownPipe } from 'src/app/pipes/markdown.pipe' -import { filter, map } from 'rxjs' import { ChatMessage, ChatService, @@ -48,6 +48,9 @@ export class ChatComponent implements OnInit { private typewriterBuffer: string[] = [] private typewriterActive = false + private typewriterTimeout: ReturnType + private streamSubscription: Subscription + private streamingMessage: ChatMessage private slowResponseTimeout: ReturnType public get placeholder(): string { @@ -87,6 +90,7 @@ export class ChatComponent implements OnInit { } this.messages.update((messages) => [...messages, assistantMessage]) this.loading.set(true) + this.streamingMessage = assistantMessage this.slowResponseTimeout = setTimeout( () => this.slowResponse.set(true), SLOW_RESPONSE_DELAY_MS @@ -94,44 +98,68 @@ export class ChatComponent implements OnInit { let lastVisibleContent = '' - this.chatService.streamChat(this.documentId(), this.input()).subscribe({ - next: (chunk) => { - this.clearSlowResponse() - const nextResponse = parseChatResponse(chunk) + this.streamSubscription = this.chatService + .streamChat(this.documentId(), this.input()) + .subscribe({ + next: (chunk) => { + this.clearSlowResponse() + const nextResponse = parseChatResponse(chunk) - if (nextResponse.content.length < lastVisibleContent.length) { - this.resetTypewriter(assistantMessage, nextResponse.content) - lastVisibleContent = nextResponse.content - } else { - const visibleDelta = nextResponse.content.substring( - lastVisibleContent.length - ) - lastVisibleContent = nextResponse.content - this.enqueueTypewriter(visibleDelta, assistantMessage) - } + if (nextResponse.content.length < lastVisibleContent.length) { + this.resetTypewriter(assistantMessage, nextResponse.content) + lastVisibleContent = nextResponse.content + } else { + const visibleDelta = nextResponse.content.substring( + lastVisibleContent.length + ) + lastVisibleContent = nextResponse.content + this.enqueueTypewriter(visibleDelta, assistantMessage) + } - assistantMessage.references = nextResponse.references - this.notifyMessagesChanged() - }, - error: () => { - this.clearSlowResponse() - assistantMessage.content += '\n\n⚠️ Error receiving response.' - assistantMessage.isStreaming = false - this.notifyMessagesChanged() - this.loading.set(false) - }, - complete: () => { - this.clearSlowResponse() - assistantMessage.isStreaming = false - this.notifyMessagesChanged() - this.loading.set(false) - this.scrollToBottom() - }, - }) + assistantMessage.references = nextResponse.references + this.notifyMessagesChanged() + }, + error: () => { + this.clearSlowResponse() + assistantMessage.content += '\n\n⚠️ Error receiving response.' + assistantMessage.isStreaming = false + this.notifyMessagesChanged() + this.loading.set(false) + }, + complete: () => { + this.clearSlowResponse() + assistantMessage.isStreaming = false + this.notifyMessagesChanged() + this.loading.set(false) + this.scrollToBottom() + }, + }) this.input.set('') } + 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.clearSlowResponse() + message.isStreaming = false + this.notifyMessagesChanged() + this.loading.set(false) + } + + private flushTypewriter(message: ChatMessage): void { + message.content += this.typewriterBuffer.join('') + clearTimeout(this.typewriterTimeout) + this.typewriterBuffer = [] + this.typewriterActive = false + } + private clearSlowResponse(): void { clearTimeout(this.slowResponseTimeout) this.slowResponse.set(false) @@ -167,7 +195,8 @@ export class ChatComponent implements OnInit { this.notifyMessagesChanged() this.scrollToBottom() - setTimeout(() => this.playTypewriter(message), 10) // 10ms per character + // 10ms per character + this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10) } private notifyMessagesChanged(): void {