<!-- resources/js/components/ChatInterface.vue -->
<template>
<div class="chat-container">
<div class="chat-messages" ref="messagesContainer">
<div
v-for="(msg, index) in messages"
:key="index"
:class="['message', msg.role === 'user' ? 'message-user' : 'message-ai']"
>
<div class="message-content">
<!-- Use v-html to render the markdown parsed by 'marked' -->
<!-- NOTE: Sanitize this in production if you don't trust the AI output -->
<div v-if="msg.role === 'assistant'" v-html="renderMarkdown(msg.content)"></div>
<div v-else>{{ msg.content }}</div>
<!-- Display the uploaded image if it exists -->
<img v-if="msg.imageUrl" :src="msg.imageUrl" class="message-image" />
</div>
</div>
<div v-if="isTyping" class="message message-ai">
<span class="typing-indicator">AI is typing...</span>
</div>
</div>
<div class="chat-input">
<!-- Hidden file input for multimodal uploads -->
<input
type="file"
ref="fileInput"
@change="handleFileUpload"
accept="image/*"
style="display: none;"
/>
<button type="button" @click="$refs.fileInput.click()" class="btn-attach">
📎
</button>
<input
v-model="newMessage"
@keyup.enter="sendMessage"
type="text"
placeholder="Type your message..."
:disabled="isTyping"
/>
<button @click="sendMessage" :disabled="isTyping || !newMessage">Send</button>
</div>
<div v-if="tempImageUrl" class="image-preview">
Attached: {{ tempImageName }}
<button @click="clearAttachment">x</button>
</div>
</div>
</template>
<script>
import { marked } from 'marked';
export default {
name: 'ChatInterface',
data() {
return {
messages: [],
newMessage: '',
isTyping: false,
eventSource: null,
tempImageUrl: null, // URL of the image *after* uploading to Laravel
tempImageName: null
};
},
methods: {
renderMarkdown(text) {
return marked.parse(text);
},
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messagesContainer;
container.scrollTop = container.scrollHeight;
});
},
async handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
this.tempImageName = file.name;
const formData = new FormData();
formData.append('image', file);
try {
// 1. Upload the image to Laravel FIRST
const response = await axios.post('/api/chat/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
// 2. Laravel saves it (e.g., to S3 or public disk) and returns the URL
this.tempImageUrl = response.data.url;
} catch (error) {
alert('Failed to upload image');
this.clearAttachment();
}
},
clearAttachment() {
this.tempImageUrl = null;
this.tempImageName = null;
this.$refs.fileInput.value = '';
},
sendMessage() {
if (this.newMessage.trim() === '' && !this.tempImageUrl) return;
const userMessage = this.newMessage;
const imageUrl = this.tempImageUrl;
// Add user message to the UI immediately
this.messages.push({
role: 'user',
content: userMessage,
imageUrl: imageUrl
});
this.newMessage = '';
this.clearAttachment();
this.isTyping = true;
this.scrollToBottom();
// Prepare a new empty message for the AI response
this.messages.push({ role: 'assistant', content: '' });
const aiMessageIndex = this.messages.length - 1;
// Construct the URL with parameters
let url = `/api/chat/stream?message=${encodeURIComponent(userMessage)}`;
if (imageUrl) {
url += `&imageUrl=${encodeURIComponent(imageUrl)}`;
}
// Initialize SSE
this.eventSource = new EventSource(url);
this.eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.event === 'done') {
this.closeConnection();
return;
}
// Append the incoming token to the last message
this.messages[aiMessageIndex].content += data.token;
this.scrollToBottom();
};
this.eventSource.onerror = (err) => {
console.error("EventSource failed:", err);
this.closeConnection();
// Optional: Add an error message to the UI
this.messages[aiMessageIndex].content += "\n\n*[Error: Connection interrupted]*";
};
},
closeConnection() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
this.isTyping = false;
}
},
beforeDestroy() {
this.closeConnection();
}
};
</script>
<style scoped>
/* Very Basic Styling */
.chat-container { display: flex; flex-direction: column; height: 500px; border: 1px solid #ccc; }
.chat-messages { flex-grow: 1; overflow-y: auto; padding: 15px; }
.message { margin-bottom: 15px; padding: 10px; border-radius: 8px; max-width: 80%; }
.message-user { align-self: flex-end; background-color: #dcf8c6; margin-left: auto; }
.message-ai { align-self: flex-start; background-color: #f1f0f0; }
.chat-input { display: flex; padding: 10px; border-top: 1px solid #ccc; }
.chat-input input[type="text"] { flex-grow: 1; margin: 0 10px; }
.message-image { max-width: 200px; margin-top: 10px; border-radius: 5px; }
.image-preview { padding: 5px 10px; background: #eee; font-size: 0.9em; }
</style>