fix
Browse files- src/app/chat/chat.component.html +99 -98
- src/app/chat/chat.component.ts +109 -73
src/app/chat/chat.component.html
CHANGED
|
@@ -73,109 +73,110 @@
|
|
| 73 |
</div>
|
| 74 |
|
| 75 |
<!-- AI message -->
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
<
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
<div class="message structured-response">
|
| 82 |
-
<div [innerHTML]="formatStructuredResponse(message.text)"></div>
|
| 83 |
-
|
| 84 |
-
<!-- Follow-ups -->
|
| 85 |
-
<div class="followups" *ngIf="message.suggestions?.length">
|
| 86 |
-
<div class="followups-title">Follow-up suggestions</div>
|
| 87 |
-
<button class="followup-chip"
|
| 88 |
-
*ngFor="let s of message.suggestions"
|
| 89 |
-
(click)="selectHardcodedQuestion(s)"
|
| 90 |
-
title="Ask this next">
|
| 91 |
-
{{ s }}
|
| 92 |
-
</button>
|
| 93 |
</div>
|
| 94 |
|
| 95 |
-
<div class="message-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
<!--
|
| 99 |
-
<
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
<
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
<img src="assets/images/chat/
|
| 137 |
-
</
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
</div>
|
| 167 |
-
|
| 168 |
-
<!-- Inline video player -->
|
| 169 |
-
<video *ngIf="isVideoEnabledIndex[i] && message.playingVideoUrl"
|
| 170 |
-
id="inline-video-{{i}}"
|
| 171 |
-
class="tutor-video"
|
| 172 |
-
[src]="message.playingVideoUrl"
|
| 173 |
-
(ended)="onMessageVideoEnded(i)"
|
| 174 |
-
controls autoplay>
|
| 175 |
-
</video>
|
| 176 |
</div>
|
| 177 |
</div>
|
| 178 |
-
</div>
|
| 179 |
|
| 180 |
<!-- Typing indicator -->
|
| 181 |
<div *ngIf="isTyping" class="typing-indicator" aria-live="polite">
|
|
|
|
| 73 |
</div>
|
| 74 |
|
| 75 |
<!-- AI message -->
|
| 76 |
+
|
| 77 |
+
<div *ngIf="message.from === 'ai'" class="message-wrapper ai">
|
| 78 |
+
<div class="profile-pic">
|
| 79 |
+
<img src="assets/images/chat/natasha.png" alt="AI" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
</div>
|
| 81 |
|
| 82 |
+
<div class="message structured-response">
|
| 83 |
+
<div [innerHTML]="formatStructuredResponse(message.text)"></div>
|
| 84 |
+
|
| 85 |
+
<!-- Follow-ups -->
|
| 86 |
+
<div class="followups" *ngIf="message.suggestions?.length">
|
| 87 |
+
<div class="followups-title">Follow-up suggestions</div>
|
| 88 |
+
<button class="followup-chip"
|
| 89 |
+
*ngFor="let s of message.suggestions"
|
| 90 |
+
(click)="selectHardcodedQuestion(s)"
|
| 91 |
+
title="Ask this next">
|
| 92 |
+
{{ s }}
|
| 93 |
+
</button>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<div class="message-timestamp">
|
| 97 |
+
{{ message.timestamp }}
|
| 98 |
+
|
| 99 |
+
<!-- Copy -->
|
| 100 |
+
<button class="icon-btn" (click)="copyToClipboard(message.text, i)"
|
| 101 |
+
[attr.aria-label]="copySuccessIndex === i ? 'Copied' : 'Copy message'"
|
| 102 |
+
title="Copy message">
|
| 103 |
+
<ng-container *ngIf="copySuccessIndex === i; else showCopy">
|
| 104 |
+
<span class="copy-tick">✓</span>
|
| 105 |
+
</ng-container>
|
| 106 |
+
<ng-template #showCopy>
|
| 107 |
+
<img src="assets/images/chat/copy.png" alt="Copy" class="meta-icon" />
|
| 108 |
+
</ng-template>
|
| 109 |
+
</button>
|
| 110 |
+
|
| 111 |
+
<!-- Audio: play / stop -->
|
| 112 |
+
<button class="icon-btn"
|
| 113 |
+
*ngIf="message.audioUrl && isReadingIndex !== i"
|
| 114 |
+
(click)="playServerAudioForMessage(i)"
|
| 115 |
+
aria-label="Play audio" title="Play audio">
|
| 116 |
+
<img src="assets/images/chat/speaker.png" alt="Play audio" class="meta-icon" />
|
| 117 |
+
</button>
|
| 118 |
+
|
| 119 |
+
<button class="icon-btn"
|
| 120 |
+
*ngIf="message.audioUrl && isReadingIndex === i"
|
| 121 |
+
(click)="stopReadAloud()"
|
| 122 |
+
aria-label="Stop audio" title="Stop audio">
|
| 123 |
+
<img src="assets/images/chat/stop-button.png" alt="Stop audio" class="meta-icon" />
|
| 124 |
+
</button>
|
| 125 |
+
|
| 126 |
+
<!-- Generate audio on demand -->
|
| 127 |
+
<button class="icon-btn"
|
| 128 |
+
*ngIf="!message.audioUrl"
|
| 129 |
+
(click)="synthesizeAudioAndPlay(i)"
|
| 130 |
+
[disabled]="message.isSynthesizing"
|
| 131 |
+
aria-label="Generate audio"
|
| 132 |
+
title="Generate audio">
|
| 133 |
+
<ng-container *ngIf="!message.isSynthesizing; else audioSpinner">
|
| 134 |
+
<img src="assets/images/chat/speaker.png" alt="Generate audio" class="meta-icon" />
|
| 135 |
+
</ng-container>
|
| 136 |
+
<ng-template #audioSpinner>
|
| 137 |
+
<img src="assets/images/chat/loading-spinner.gif" alt="Generating audio" class="meta-icon" />
|
| 138 |
+
</ng-template>
|
| 139 |
+
</button>
|
| 140 |
+
|
| 141 |
+
<!-- Video: generate, play, stop -->
|
| 142 |
+
<!-- Show Generate when no cached video -->
|
| 143 |
+
<button class="icon-btn"
|
| 144 |
+
*ngIf="!message.videoUrl && !message.isVideoSynthesizing"
|
| 145 |
+
(click)="synthesizeVideoAndPlay(i)"
|
| 146 |
+
aria-label="Generate video"
|
| 147 |
+
title="Generate video">
|
| 148 |
+
<img src="assets/images/chat/video.png" alt="Generate video" class="meta-icon" />
|
| 149 |
+
</button>
|
| 150 |
+
|
| 151 |
+
<!-- Spinner while generating video -->
|
| 152 |
+
<button class="icon-btn" *ngIf="!message.videoUrl && message.isVideoSynthesizing" disabled>
|
| 153 |
+
<img src="assets/images/chat/loading-spinner.gif" alt="Generating video" class="meta-icon" />
|
| 154 |
+
</button>
|
| 155 |
+
|
| 156 |
+
<!-- Toggle inline video (single button). Show video.png initially, switches to no-video.png when enabled -->
|
| 157 |
+
<button class="icon-btn"
|
| 158 |
+
*ngIf="message.videoUrl"
|
| 159 |
+
(click)="toggleMessageVideo(i)"
|
| 160 |
+
[class.active]="isVideoEnabledIndex[i]"
|
| 161 |
+
aria-label="Toggle video"
|
| 162 |
+
title="Toggle video">
|
| 163 |
+
<img [src]="isVideoEnabledIndex[i] ? 'assets/images/chat/no-video.png' : 'assets/images/chat/video.png'"
|
| 164 |
+
alt="Video" class="meta-icon" />
|
| 165 |
+
</button>
|
| 166 |
+
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
<!-- Inline video player -->
|
| 170 |
+
<video *ngIf="isVideoEnabledIndex[i] && message.playingVideoUrl"
|
| 171 |
+
id="inline-video-{{i}}"
|
| 172 |
+
class="tutor-video"
|
| 173 |
+
[src]="message.playingVideoUrl"
|
| 174 |
+
(ended)="onMessageVideoEnded(i)"
|
| 175 |
+
controls autoplay>
|
| 176 |
+
</video>
|
| 177 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
</div>
|
| 179 |
</div>
|
|
|
|
| 180 |
|
| 181 |
<!-- Typing indicator -->
|
| 182 |
<div *ngIf="isTyping" class="typing-indicator" aria-live="polite">
|
src/app/chat/chat.component.ts
CHANGED
|
@@ -284,14 +284,7 @@ export class ChatComponent implements OnDestroy {
|
|
| 284 |
}
|
| 285 |
}
|
| 286 |
|
| 287 |
-
|
| 288 |
-
// if (e.key === 'hasPDF') this.showRagModeAlert();
|
| 289 |
-
//};
|
| 290 |
-
|
| 291 |
-
//private showRagModeAlert(): void {
|
| 292 |
-
// const ragEnabled = localStorage.getItem('rag_enabled') === '1' || localStorage.getItem('hasPDF') === 'true';
|
| 293 |
-
// // No-op placeholder
|
| 294 |
-
//}
|
| 295 |
|
| 296 |
ngOnInit(): void {
|
| 297 |
this.ensureGradeLevel();
|
|
@@ -303,11 +296,7 @@ export class ChatComponent implements OnDestroy {
|
|
| 303 |
this.loadVoices();
|
| 304 |
}
|
| 305 |
|
| 306 |
-
|
| 307 |
-
// setTimeout(() => {
|
| 308 |
-
// this.scrollToBottom();
|
| 309 |
-
// }, 100);
|
| 310 |
-
//}
|
| 311 |
|
| 312 |
ngOnDestroy(): void {
|
| 313 |
if (this.currentExplainSub) { this.currentExplainSub.unsubscribe(); this.currentExplainSub = null; }
|
|
@@ -335,13 +324,7 @@ export class ChatComponent implements OnDestroy {
|
|
| 335 |
});
|
| 336 |
}
|
| 337 |
|
| 338 |
-
|
| 339 |
-
// try {
|
| 340 |
-
// this.chatBox.nativeElement.scrollTo({ top: this.chatBox.nativeElement.scrollHeight, behavior: 'smooth' });
|
| 341 |
-
// } catch (err) {
|
| 342 |
-
// console.error('Scroll error:', err);
|
| 343 |
-
// }
|
| 344 |
-
//}
|
| 345 |
|
| 346 |
scrollToBottom(): void {
|
| 347 |
if (this.shouldAutoScroll) {
|
|
@@ -565,6 +548,7 @@ export class ChatComponent implements OnDestroy {
|
|
| 565 |
this.sendMessage();
|
| 566 |
}
|
| 567 |
|
|
|
|
| 568 |
/** Send question to backend for an answer */
|
| 569 |
sendMessage(inputText?: string): void {
|
| 570 |
const message = inputText ? inputText.trim() : this.userInput.trim();
|
|
@@ -595,49 +579,64 @@ export class ChatComponent implements OnDestroy {
|
|
| 595 |
? response.source_ids.filter((s: any) => typeof s === 'string' && s.trim().length > 0)
|
| 596 |
: [];
|
| 597 |
|
| 598 |
-
|
| 599 |
-
from: 'ai',
|
| 600 |
-
text: explanation,
|
| 601 |
-
timestamp: new Date().toLocaleTimeString(),
|
| 602 |
-
source_ids: sourceIds
|
| 603 |
-
});
|
| 604 |
-
this.cdr.detectChanges();
|
| 605 |
-
this.shouldAutoScroll = true;
|
| 606 |
-
this.scrollToBottom();
|
| 607 |
-
|
| 608 |
this.lastQuestion = message;
|
| 609 |
-
this.lastAnswer = explanation;
|
| 610 |
this.lastSourceIds = sourceIds;
|
| 611 |
|
| 612 |
-
// NEW: mark if the answer is grounded in textbook pages
|
| 613 |
const notFound = /No information available in the provided textbook content/i.test(explanation);
|
| 614 |
-
|
| 615 |
|
| 616 |
-
|
|
|
|
|
|
|
|
|
|
| 617 |
},
|
| 618 |
error: (err) => {
|
| 619 |
console.error('API Error:', err);
|
| 620 |
this.isTyping = false;
|
| 621 |
const errorMessage = 'Error: Could not get a response from the server.';
|
| 622 |
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
text: errorMessage,
|
| 626 |
-
timestamp: new Date().toLocaleTimeString(),
|
| 627 |
-
source_ids: []
|
| 628 |
-
});
|
| 629 |
-
this.cdr.detectChanges();
|
| 630 |
-
this.shouldAutoScroll = true;
|
| 631 |
-
this.scrollToBottom();
|
| 632 |
-
|
| 633 |
-
// NEW: ensure follow-ups are not attempted after an error
|
| 634 |
-
this.lastAnswerHasContext = false;
|
| 635 |
-
|
| 636 |
-
this.speakResponse(errorMessage);
|
| 637 |
}
|
| 638 |
});
|
| 639 |
}
|
| 640 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
|
| 642 |
|
| 643 |
displaySource(tag: string): string {
|
|
@@ -658,11 +657,23 @@ export class ChatComponent implements OnDestroy {
|
|
| 658 |
}
|
| 659 |
|
| 660 |
/** TTS helpers */
|
| 661 |
-
|
| 662 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 663 |
|
|
|
|
| 664 |
let aiIndex: number | null = null;
|
| 665 |
-
if (typeof targetIndex === 'number' &&
|
|
|
|
|
|
|
| 666 |
aiIndex = targetIndex;
|
| 667 |
} else {
|
| 668 |
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
@@ -671,36 +682,58 @@ export class ChatComponent implements OnDestroy {
|
|
| 671 |
}
|
| 672 |
|
| 673 |
if (aiIndex === null || aiIndex < 0 || !this.messages[aiIndex]) {
|
| 674 |
-
this.messages.push({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
aiIndex = this.messages.length - 1;
|
| 676 |
this.isVideoEnabledIndex.push(false);
|
| 677 |
}
|
| 678 |
|
| 679 |
const aiMsg = this.messages[aiIndex] as any;
|
| 680 |
|
| 681 |
-
if (this.aiResponseInterval) {
|
|
|
|
|
|
|
|
|
|
| 682 |
|
| 683 |
aiMsg.text = '';
|
|
|
|
|
|
|
| 684 |
this.cdr.detectChanges();
|
| 685 |
|
| 686 |
-
const words = responseText.split(
|
| 687 |
let idx = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
this.aiResponseInterval = setInterval(() => {
|
| 689 |
if (idx < words.length) {
|
| 690 |
aiMsg.text = words.slice(0, idx + 1).join(' ');
|
| 691 |
idx++;
|
| 692 |
this.cdr.detectChanges();
|
|
|
|
| 693 |
} else {
|
| 694 |
-
clearInterval(this.aiResponseInterval);
|
| 695 |
this.aiResponseInterval = null;
|
| 696 |
aiMsg.text = responseText;
|
| 697 |
aiMsg.pending = false;
|
| 698 |
-
this.cdr.detectChanges();
|
| 699 |
this.isAiResponding = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
}
|
| 701 |
-
},
|
| 702 |
}
|
| 703 |
|
|
|
|
|
|
|
| 704 |
stopAiResponse(): void {
|
| 705 |
if (this.currentExplainSub) { this.currentExplainSub.unsubscribe(); this.currentExplainSub = null; }
|
| 706 |
if (this.currentFollowupsSub) { this.currentFollowupsSub.unsubscribe(); this.currentFollowupsSub = null; }
|
|
@@ -748,13 +781,19 @@ export class ChatComponent implements OnDestroy {
|
|
| 748 |
|
| 749 |
speakResponse(responseText: string): void {
|
| 750 |
if (!responseText) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
this.stopAllVideo();
|
|
|
|
| 752 |
const speech = new SpeechSynthesisUtterance();
|
| 753 |
speech.text = responseText;
|
| 754 |
speech.lang = 'en-US';
|
| 755 |
speech.pitch = 1;
|
| 756 |
speech.rate = 1;
|
| 757 |
this.isSpeaking = true;
|
|
|
|
| 758 |
const voices = window.speechSynthesis.getVoices();
|
| 759 |
const preferred = [
|
| 760 |
'Google UK English Female',
|
|
@@ -769,24 +808,21 @@ export class ChatComponent implements OnDestroy {
|
|
| 769 |
if (found) { speech.voice = found; break; }
|
| 770 |
}
|
| 771 |
if (!speech.voice && voices.length) speech.voice = voices[0];
|
| 772 |
-
|
| 773 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 774 |
}
|
| 775 |
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
// this.serverAudio.pause();
|
| 779 |
-
// this.isAudioPaused = true;
|
| 780 |
-
// if (this.serverAudioMessageIndex !== null) this.messages[this.serverAudioMessageIndex].isPlaying = false;
|
| 781 |
-
// this.cdr.detectChanges();
|
| 782 |
-
// return;
|
| 783 |
-
// }
|
| 784 |
-
// if (window.speechSynthesis && window.speechSynthesis.speaking && !window.speechSynthesis.paused) {
|
| 785 |
-
// window.speechSynthesis.pause();
|
| 786 |
-
// this.isAudioPaused = true;
|
| 787 |
-
// this.cdr.detectChanges();
|
| 788 |
-
// }
|
| 789 |
-
//}
|
| 790 |
|
| 791 |
resumeAudio(): void {
|
| 792 |
if (this.serverAudio && this.serverAudio.paused) {
|
|
|
|
| 284 |
}
|
| 285 |
}
|
| 286 |
|
| 287 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
|
| 289 |
ngOnInit(): void {
|
| 290 |
this.ensureGradeLevel();
|
|
|
|
| 296 |
this.loadVoices();
|
| 297 |
}
|
| 298 |
|
| 299 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
ngOnDestroy(): void {
|
| 302 |
if (this.currentExplainSub) { this.currentExplainSub.unsubscribe(); this.currentExplainSub = null; }
|
|
|
|
| 324 |
});
|
| 325 |
}
|
| 326 |
|
| 327 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
scrollToBottom(): void {
|
| 330 |
if (this.shouldAutoScroll) {
|
|
|
|
| 548 |
this.sendMessage();
|
| 549 |
}
|
| 550 |
|
| 551 |
+
/** Send question to backend for an answer */
|
| 552 |
/** Send question to backend for an answer */
|
| 553 |
sendMessage(inputText?: string): void {
|
| 554 |
const message = inputText ? inputText.trim() : this.userInput.trim();
|
|
|
|
| 579 |
? response.source_ids.filter((s: any) => typeof s === 'string' && s.trim().length > 0)
|
| 580 |
: [];
|
| 581 |
|
| 582 |
+
// Store question + source ids *now*.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 583 |
this.lastQuestion = message;
|
|
|
|
| 584 |
this.lastSourceIds = sourceIds;
|
| 585 |
|
|
|
|
| 586 |
const notFound = /No information available in the provided textbook content/i.test(explanation);
|
| 587 |
+
const hasContext = !!sourceIds.length && !notFound;
|
| 588 |
|
| 589 |
+
// IMPORTANT:
|
| 590 |
+
// Do NOT set this.lastAnswer here.
|
| 591 |
+
// We will set it only after the streaming animation has finished.
|
| 592 |
+
this.streamAiAnswer(explanation, sourceIds, hasContext);
|
| 593 |
},
|
| 594 |
error: (err) => {
|
| 595 |
console.error('API Error:', err);
|
| 596 |
this.isTyping = false;
|
| 597 |
const errorMessage = 'Error: Could not get a response from the server.';
|
| 598 |
|
| 599 |
+
// We can still stream the error text (or you can show it directly if you prefer)
|
| 600 |
+
this.streamAiAnswer(errorMessage, [], false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
}
|
| 602 |
});
|
| 603 |
}
|
| 604 |
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
/** Show AI answer word-by-word and start audio */
|
| 608 |
+
/** Show AI answer word-by-word and start audio */
|
| 609 |
+
private streamAiAnswer(explanation: string, sourceIds: string[], hasContext: boolean): void {
|
| 610 |
+
const text = (explanation || '').trim() || 'No explanation available.';
|
| 611 |
+
const timestamp = new Date().toLocaleTimeString();
|
| 612 |
+
|
| 613 |
+
// Create an empty AI message first
|
| 614 |
+
const aiIndex = this.messages.push({
|
| 615 |
+
from: 'ai',
|
| 616 |
+
text: '',
|
| 617 |
+
timestamp,
|
| 618 |
+
source_ids: sourceIds,
|
| 619 |
+
pending: true
|
| 620 |
+
} as any) - 1;
|
| 621 |
+
|
| 622 |
+
this.isAiResponding = true;
|
| 623 |
+
this.shouldAutoScroll = true;
|
| 624 |
+
this.cdr.detectChanges();
|
| 625 |
+
|
| 626 |
+
// Animate the text word by word
|
| 627 |
+
this.animateAiResponse(text, aiIndex, () => {
|
| 628 |
+
// When streaming is finished, we finally store lastAnswer
|
| 629 |
+
this.lastAnswer = text;
|
| 630 |
+
this.lastAnswerHasContext = hasContext;
|
| 631 |
+
});
|
| 632 |
+
|
| 633 |
+
// Play audio at the same time (only if voice is enabled)
|
| 634 |
+
this.speakResponse(text);
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
|
| 640 |
|
| 641 |
|
| 642 |
displaySource(tag: string): string {
|
|
|
|
| 657 |
}
|
| 658 |
|
| 659 |
/** TTS helpers */
|
| 660 |
+
/** TTS helpers */
|
| 661 |
+
/** TTS helpers + typing animation */
|
| 662 |
+
animateAiResponse(
|
| 663 |
+
responseText: string,
|
| 664 |
+
targetIndex?: number,
|
| 665 |
+
onDone?: () => void
|
| 666 |
+
): void {
|
| 667 |
+
if (!responseText) {
|
| 668 |
+
this.isAiResponding = false;
|
| 669 |
+
return;
|
| 670 |
+
}
|
| 671 |
|
| 672 |
+
// Find or create the AI message to animate into
|
| 673 |
let aiIndex: number | null = null;
|
| 674 |
+
if (typeof targetIndex === 'number' &&
|
| 675 |
+
this.messages[targetIndex] &&
|
| 676 |
+
this.messages[targetIndex].from === 'ai') {
|
| 677 |
aiIndex = targetIndex;
|
| 678 |
} else {
|
| 679 |
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
|
|
| 682 |
}
|
| 683 |
|
| 684 |
if (aiIndex === null || aiIndex < 0 || !this.messages[aiIndex]) {
|
| 685 |
+
this.messages.push({
|
| 686 |
+
from: 'ai',
|
| 687 |
+
text: '',
|
| 688 |
+
timestamp: new Date().toLocaleTimeString()
|
| 689 |
+
} as any);
|
| 690 |
aiIndex = this.messages.length - 1;
|
| 691 |
this.isVideoEnabledIndex.push(false);
|
| 692 |
}
|
| 693 |
|
| 694 |
const aiMsg = this.messages[aiIndex] as any;
|
| 695 |
|
| 696 |
+
if (this.aiResponseInterval) {
|
| 697 |
+
clearInterval(this.aiResponseInterval);
|
| 698 |
+
this.aiResponseInterval = null;
|
| 699 |
+
}
|
| 700 |
|
| 701 |
aiMsg.text = '';
|
| 702 |
+
aiMsg.pending = true;
|
| 703 |
+
this.isAiResponding = true;
|
| 704 |
this.cdr.detectChanges();
|
| 705 |
|
| 706 |
+
const words = responseText.split(/\s+/).filter(w => w.length);
|
| 707 |
let idx = 0;
|
| 708 |
+
|
| 709 |
+
// 1 word every 200 ms – you can tune this
|
| 710 |
+
const speedMs = 200;
|
| 711 |
+
|
| 712 |
this.aiResponseInterval = setInterval(() => {
|
| 713 |
if (idx < words.length) {
|
| 714 |
aiMsg.text = words.slice(0, idx + 1).join(' ');
|
| 715 |
idx++;
|
| 716 |
this.cdr.detectChanges();
|
| 717 |
+
this.scrollToBottom(); // keep view at bottom while streaming
|
| 718 |
} else {
|
| 719 |
+
clearInterval(this.aiResponseInterval!);
|
| 720 |
this.aiResponseInterval = null;
|
| 721 |
aiMsg.text = responseText;
|
| 722 |
aiMsg.pending = false;
|
|
|
|
| 723 |
this.isAiResponding = false;
|
| 724 |
+
|
| 725 |
+
if (onDone) {
|
| 726 |
+
onDone();
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
this.cdr.detectChanges();
|
| 730 |
+
this.scrollToBottom();
|
| 731 |
}
|
| 732 |
+
}, speedMs);
|
| 733 |
}
|
| 734 |
|
| 735 |
+
|
| 736 |
+
|
| 737 |
stopAiResponse(): void {
|
| 738 |
if (this.currentExplainSub) { this.currentExplainSub.unsubscribe(); this.currentExplainSub = null; }
|
| 739 |
if (this.currentFollowupsSub) { this.currentFollowupsSub.unsubscribe(); this.currentFollowupsSub = null; }
|
|
|
|
| 781 |
|
| 782 |
speakResponse(responseText: string): void {
|
| 783 |
if (!responseText) return;
|
| 784 |
+
|
| 785 |
+
// Only speak when the Voice toggle is ON
|
| 786 |
+
if (!this.isVoiceEnabled) return;
|
| 787 |
+
|
| 788 |
this.stopAllVideo();
|
| 789 |
+
|
| 790 |
const speech = new SpeechSynthesisUtterance();
|
| 791 |
speech.text = responseText;
|
| 792 |
speech.lang = 'en-US';
|
| 793 |
speech.pitch = 1;
|
| 794 |
speech.rate = 1;
|
| 795 |
this.isSpeaking = true;
|
| 796 |
+
|
| 797 |
const voices = window.speechSynthesis.getVoices();
|
| 798 |
const preferred = [
|
| 799 |
'Google UK English Female',
|
|
|
|
| 808 |
if (found) { speech.voice = found; break; }
|
| 809 |
}
|
| 810 |
if (!speech.voice && voices.length) speech.voice = voices[0];
|
| 811 |
+
|
| 812 |
+
speech.onend = () => {
|
| 813 |
+
this.isSpeaking = false;
|
| 814 |
+
this.cdr.detectChanges();
|
| 815 |
+
};
|
| 816 |
+
|
| 817 |
+
try {
|
| 818 |
+
window.speechSynthesis.speak(speech);
|
| 819 |
+
} catch {
|
| 820 |
+
this.isSpeaking = false;
|
| 821 |
+
}
|
| 822 |
}
|
| 823 |
|
| 824 |
+
|
| 825 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 826 |
|
| 827 |
resumeAudio(): void {
|
| 828 |
if (this.serverAudio && this.serverAudio.paused) {
|