Files
eveAI/eveai_chat_client/static/assets/vue-components/MessageHistory.vue

449 lines
15 KiB
Vue

<template>
<div class="message-history-container">
<!-- Normal messages container -->
<div class="chat-messages" ref="messagesContainer">
<!-- Loading indicator for load more -->
<div v-if="$slots.loading" class="load-more-indicator">
<slot name="loading"></slot>
</div>
<!-- Messages wrapper for bottom alignment -->
<div class="messages-wrapper">
<!-- Empty state (only show when no messages) -->
<div v-if="normalMessages.length === 0" class="empty-state">
<div class="empty-icon">💬</div>
<div class="empty-text">Nog geen berichten</div>
<div class="empty-subtext">Start een gesprek door een bericht te typen!</div>
</div>
<!-- Normal message list (excluding temporarily positioned AI messages) -->
<template v-if="normalMessages.length > 0">
<!-- Messages -->
<template v-for="(message, index) in normalMessages" :key="message.id">
<!-- The actual message -->
<chat-message
:message="message"
:is-submitting-form="isSubmittingForm"
:api-prefix="apiPrefix"
:is-latest-ai-message="isLatestAiMessage(message)"
@image-loaded="handleImageLoaded"
@specialist-complete="$emit('specialist-complete', $event)"
@specialist-error="$emit('specialist-error', $event)"
></chat-message>
</template>
</template>
<!-- Typing indicator -->
<typing-indicator v-if="isTyping"></typing-indicator>
</div>
</div>
</div>
</template>
<script>
import ChatMessage from './ChatMessage.vue';
import TypingIndicator from './TypingIndicator.vue';
import { useTranslationClient } from '../js/composables/useTranslation.js';
export default {
name: 'MessageHistory',
components: {
'chat-message': ChatMessage,
'typing-indicator': TypingIndicator
},
setup() {
const { translateSafe } = useTranslationClient();
return {
translateSafe
};
},
props: {
messages: {
type: Array,
default: () => []
},
isTyping: {
type: Boolean,
default: false
},
isSubmittingForm: {
type: Boolean,
default: false
},
apiPrefix: {
type: String,
default: ''
},
autoScroll: {
type: Boolean,
default: true
}
},
emits: ['load-more', 'specialist-complete', 'specialist-error'],
data() {
return {
isAtBottom: true,
showScrollButton: false,
unreadCount: 0,
languageChangeHandler: null,
_prevSnapshot: { length: 0, firstId: null, lastId: null },
};
},
computed: {
// Messages that should be shown in normal flow (excluding temporarily positioned AI messages)
normalMessages() {
return this.messages.filter(msg => !msg.isTemporarilyAtBottom);
},
},
watch: {
messages: {
async handler(newMessages, oldMessages) {
const prev = this._prevSnapshot || { length: 0, firstId: null, lastId: null };
const curr = this.makeSnapshot(newMessages);
const container = this.$refs.messagesContainer;
const lengthIncreased = curr.length > prev.length;
const lengthDecreased = curr.length < prev.length; // reset/trim
const appended = lengthIncreased && curr.lastId !== prev.lastId;
const prepended = lengthIncreased && curr.firstId !== prev.firstId && curr.lastId === prev.lastId;
const mutatedLastSameLength = curr.length === prev.length && curr.lastId === prev.lastId;
if (prepended && container) {
// Load-more bovenaan: positie behouden
const before = container.scrollHeight;
await this.nextFrame();
await this.nextFrame();
this.preserveScrollOnPrepend(before);
} else if (appended) {
// Nieuw bericht onderaan: altijd naar beneden (eis)
await this.scrollToBottom(true, { smooth: true, retries: 2 });
} else if (mutatedLastSameLength) {
// Laatste bericht groeit (AI streaming/media). Alleen sticky als we al onderaan waren of autoScroll actief is
if (this.autoScroll || this.isAtBottom) {
await this.scrollToBottom(false, { smooth: true, retries: 2 });
}
} else if (lengthDecreased && this.autoScroll) {
// Lijst verkort (reset): terug naar onderen
await this.scrollToBottom(true, { smooth: false, retries: 2 });
}
this._prevSnapshot = curr;
},
deep: true,
immediate: false,
},
},
created() {
// Maak een benoemde handler voor betere cleanup
this.languageChangeHandler = (event) => {
if (event.detail && event.detail.language) {
this.handleLanguageChange(event);
}
};
// Luister naar taalwijzigingen
document.addEventListener('language-changed', this.languageChangeHandler);
},
mounted() {
this.setupScrollListener();
if (this.autoScroll) {
this.$nextTick(() => this.scrollToBottom(true, { smooth: false, retries: 2 }));
}
this._onResize = this.debounce(() => {
if (this.autoScroll || this.isAtBottom) {
this.scrollToBottom(false, { smooth: true, retries: 1 });
}
}, 150);
window.addEventListener('resize', this._onResize);
const wrapper = this.$el.querySelector('.messages-wrapper');
if (window.ResizeObserver && wrapper) {
this._resizeObserver = new ResizeObserver(() => {
if (this.autoScroll || this.isAtBottom) {
this.scrollToBottom(false, { smooth: true, retries: 1 });
}
});
this._resizeObserver.observe(wrapper);
}
},
beforeUnmount() {
const container = this.$refs.messagesContainer;
if (container) container.removeEventListener('scroll', this.handleScroll);
if (this.languageChangeHandler) {
document.removeEventListener('language-changed', this.languageChangeHandler);
}
if (this._onResize) window.removeEventListener('resize', this._onResize);
if (this._resizeObserver) this._resizeObserver.disconnect();
},
methods: {
async handleLanguageChange(event) {
// Controleer of dit het eerste bericht is in een gesprek met maar één bericht
if (this.messages.length === 1 && this.messages[0].sender === 'ai') {
const firstMessage = this.messages[0];
// Controleer of we een originele inhoud hebben om te vertalen
if (firstMessage.content) {
if (!firstMessage.originalContent) {
firstMessage.originalContent = firstMessage.content;
}
console.log('Originele inhoud van eerste AI bericht:', firstMessage.originalContent);
console.log('Content eerste AI bericht:', firstMessage.content);
console.log('Vertaling van eerste AI bericht naar:', event.detail.language);
try {
// Gebruik moderne translateSafe composable
const translatedText = await this.translateSafe(
firstMessage.originalContent,
event.detail.language,
{
context: 'chat_message',
apiPrefix: this.apiPrefix,
fallbackText: firstMessage.originalContent
}
);
// Update de inhoud van het bericht
firstMessage.content = translatedText;
console.log('Eerste bericht succesvol vertaald');
} catch (error) {
console.error('Fout bij vertalen eerste bericht:', error);
// Fallback naar originele inhoud
firstMessage.content = firstMessage.originalContent;
}
}
}
},
async nextFrame() {
await this.$nextTick();
await new Promise(r => requestAnimationFrame(r));
},
async scrollToBottom(force = false, { smooth = true, retries = 2 } = {}) {
const container = this.$refs.messagesContainer;
if (!container) return;
const doScroll = (instant = false) => {
const behavior = instant ? 'auto' : (smooth ? 'smooth' : 'auto');
container.scrollTo({ top: container.scrollHeight, behavior });
};
// Wacht enkele frames om late layout/afbeeldingen te vangen
for (let i = 0; i < retries; i++) {
await this.nextFrame();
}
// Probeer smooth naar onder
doScroll(false);
// Forceer desnoods na nog een frame een harde correctie
if (force) {
await this.nextFrame();
container.scrollTop = container.scrollHeight;
}
this.isAtBottom = true;
this.showScrollButton = false;
this.unreadCount = 0;
},
makeSnapshot(list) {
const length = list.length;
const firstId = length ? list[0].id : null;
const lastId = length ? list[length - 1].id : null;
return { length, firstId, lastId };
},
preserveScrollOnPrepend(beforeHeight) {
const container = this.$refs.messagesContainer;
if (!container) return;
const afterHeight = container.scrollHeight;
const delta = afterHeight - beforeHeight;
container.scrollTop = container.scrollTop + delta;
},
setupScrollListener() {
const container = this.$refs.messagesContainer;
if (!container) return;
container.addEventListener('scroll', this.handleScroll);
},
debounce(fn, wait = 150) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), wait);
};
},
handleScroll() {
const container = this.$refs.messagesContainer;
if (!container) return;
const threshold = 80; // was 50
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
this.isAtBottom = isNearBottom;
if (container.scrollTop === 0) this.$emit('load-more');
},
handleImageLoaded() {
// Auto-scroll when img load to maintain position
if (this.isAtBottom) {
this.$nextTick(() => this.scrollToBottom());
}
},
searchMessages(query) {
// Simple message search
if (!query.trim()) return this.messages;
const searchTerm = query.toLowerCase();
return this.messages.filter(message =>
message.content &&
message.content.toLowerCase().includes(searchTerm)
);
},
isLatestAiMessage(message) {
// Only AI messages with taskId can be "latest"
if (message.sender !== 'ai' || !message.taskId) {
return false;
}
// Find the latest AI message with a taskId by iterating in reverse order
// The latest AI message is the one where the specialist interaction is still active
for (let i = this.messages.length - 1; i >= 0; i--) {
const msg = this.messages[i];
if (msg.sender === 'ai' && msg.taskId) {
// This is the latest AI message with a taskId
return msg.id === message.id;
}
}
return false;
}
}
};
</script>
<style scoped>
.message-history-container {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
min-height: 0; /* Laat kinderen scrollen */
padding: 20px;
box-sizing: border-box;
width: 100%;
max-width: 1000px;
margin-left: auto;
margin-right: auto;
/* overflow: hidden; // mag weg of blijven; met stap 1 clipt dit niet meer */
}
.chat-messages {
flex: 1;
overflow-y: auto;
scrollbar-gutter: stable both-edges; /* houdt ruimte vrij voor scrollbar */
padding-right: 0; /* haal de hack weg */
margin-right: 0;
/* Belangrijk: haal min-height: 100% weg en vervang */
/* min-height: 100%; */
min-height: 0; /* toestaan dat het kind krimpt voor overflow */
-webkit-overflow-scrolling: touch; /* betere iOS scroll */
}
.messages-wrapper {
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 100%;
gap: 10px; /* Space between messages */
}
.load-more-indicator {
text-align: center;
padding: 10px;
color: #666;
font-size: 0.9rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
color: #666;
padding: 40px 20px;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-text {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 8px;
color: #333;
}
.empty-subtext {
font-size: 0.9rem;
color: #666;
max-width: 300px;
line-height: 1.4;
}
/* Scrollbar styling */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.chat-messages {
padding: 8px;
}
.empty-state {
padding: 20px 16px;
}
.empty-icon {
font-size: 2.5rem;
}
.empty-text {
font-size: 1.1rem;
}
}
</style>