Files
panel/resources/js/pages/apps/chat.vue
2025-09-28 13:46:57 +03:30

1959 lines
67 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, nextTick, computed, watch, onMounted } from "vue";
import { useTheme } from "vuetify";
import { themes } from "@/plugins/vuetify/theme";
import { PerfectScrollbar } from "vue3-perfect-scrollbar";
// Constants
const MAX_FILE_SIZE = 5 * 1024 * 1024;
const MAX_FILES = 5;
const ALLOWED_FILE_TYPES = [
"image/jpeg", "image/png", "image/gif", "application/pdf",
"application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"text/plain"
];
const QUICK_REPLIES = [
"Resource Management", "Task Management", "New Project"
];
const COMMANDS = ["/start", "/help", "/pricing", "/faq", "/cancel"];
const MODELS = ref([
{ identifier: "model1", name: "ChatBot Basic", color: "primary", bgColor: "primary-lighten-5" },
{ identifier: "model2", name: "ChatBot Pro", color: "success", bgColor: "success-lighten-5" },
{ identifier: "model3", name: "ChatBot Advanced", color: "warning", bgColor: "warning-lighten-5" }
]);
// Page configuration
definePage({
meta: {
layoutWrapperClasses: "layout-content-height-fixed",
layoutNavbar: false,
hideFooter: true,
},
});
// Theme
const { name } = useTheme();
const chatContentContainerBg = computed(
() => themes?.[name.value]?.colors?.background || "transparent"
);
// Core state
const messages = ref([]);
const msg = ref("");
const refInputEl = ref();
const scrollEl = ref();
const isComposing = ref(false);
const isBotTyping = ref(false);
const isRequestCancelled = ref(false);
const abortController = ref(null);
// UI state
const isInputCentered = ref(true);
const showModelMenu = ref(false);
const showProjectMenu = ref(false);
const showCommandList = ref(false);
const showSuggestions = ref(false);
const showUndoPopup = ref(false);
const selectedSuggestionIndex = ref(-1);
const isWebSearchEnabled = ref(false);
// File handling
const isUploadModalOpen = ref(false);
const selectedAttachments = ref([]);
const fileInputRef = ref(null);
const fileError = ref("");
// Form management
const activeForm = ref(null);
const activeMultiForm = ref(null);
const currentFormStep = ref(0);
const formResponses = ref({});
const multiFormData = ref({});
const answeredForms = ref(new Set());
// Confirmation system
const showConfirmation = ref(false);
const showStepSelection = ref(false);
const confirmationData = ref({});
const availableSteps = ref([]);
const pendingConfirmationFormId = ref(null);
// Project management
const savedProjects = JSON.parse(localStorage.getItem("projectOptions") || "[]");
const projectOptions = ref([...savedProjects, "+ New Project"]);
const selectedProject = ref("");
// Model management
const selectedModelIdentifier = ref(MODELS.value[0].identifier);
// Message queue
const messageQueue = ref([]);
const isProcessingQueue = ref(false);
// Suggestions
const suggestions = ref([]);
const filteredCommands = ref([]);
// Undo functionality
const chatBackup = ref(null);
const undoTimer = ref(null);
const undoProgress = ref(100);
const undoInterval = ref(null);
const formatRecordingTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// Voice Recording
const isRecording = ref(false);
const mediaRecorder = ref(null);
const audioChunks = ref([]);
const recordingStartTime = ref(null);
const recordingDuration = ref(0);
const recordingInterval = ref(null);
const voiceMessage = ref(null);
const audioStream = ref(null);
const audioContext = ref(null);
const analyser = ref(null);
const dataArray = ref(null);
const animationFrame = ref(null);
const waveformData = ref(new Array(50).fill(0));
const waveformCanvas = ref(null);
const canvasCtx = ref(null);
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioStream.value = stream;
mediaRecorder.value = new MediaRecorder(stream);
audioChunks.value = [];
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
analyser.value = audioContext.value.createAnalyser();
analyser.value.smoothingTimeConstant = 0.9;
const source = audioContext.value.createMediaStreamSource(stream);
analyser.value.fftSize = 2048;
analyser.value.smoothingTimeConstant = 0.7;
source.connect(analyser.value);
const bufferLength = analyser.value.fftSize;
dataArray.value = new Uint8Array(bufferLength);
mediaRecorder.value.ondataavailable = (event) => {
audioChunks.value.push(event.data);
};
mediaRecorder.value.onstop = () => {
const audioBlob = new Blob(audioChunks.value, { type: "audio/wav" });
voiceMessage.value = audioBlob;
stopAudioAnalysis();
};
mediaRecorder.value.start();
isRecording.value = true;
recordingStartTime.value = Date.now();
recordingInterval.value = setInterval(() => {
recordingDuration.value = Math.floor((Date.now() - recordingStartTime.value) / 1000);
}, 1000);
nextTick(() => {
if (waveformCanvas.value) {
canvasCtx.value = waveformCanvas.value.getContext("2d");
drawWaveform();
}
});
} catch (error) {
console.error("Error accessing microphone:", error);
}
};
const drawWaveform = () => {
if (!isRecording.value || !analyser.value || !canvasCtx.value) return;
analyser.value.getByteTimeDomainData(dataArray.value);
const canvas = waveformCanvas.value;
const ctx = canvasCtx.value;
const width = canvas.width;
const height = canvas.height;
ctx.clearRect(0, 0, width, height);
ctx.lineWidth = 2;
ctx.strokeStyle = `rgb(${getComputedStyle(document.documentElement)
.getPropertyValue(`--v-theme-${activeModelColor.value}`)
.trim()})`;
ctx.beginPath();
const sliceWidth = (width * 1.0) / dataArray.value.length;
let x = 0;
for (let i = 0; i < dataArray.value.length; i++) {
const v = dataArray.value[i] / 128.0;
const y = (v * height) / 2;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
x += sliceWidth;
}
ctx.lineTo(width, height / 2);
ctx.stroke();
animationFrame.value = requestAnimationFrame(drawWaveform);
};
const animateWaveform = () => {
if (!isRecording.value || !analyser.value) return;
analyser.value.getByteFrequencyData(dataArray.value);
const chunkSize = Math.floor(dataArray.value.length / 50);
for (let i = 0; i < 50; i++) {
let sum = 0;
const start = i * chunkSize;
const end = Math.min(start + chunkSize, dataArray.value.length);
for (let j = start; j < end; j++) {
sum += dataArray.value[j];
}
const average = sum / (end - start);
let normalizedValue = (average / 255) * 100;
waveformData.value[i] = waveformData.value[i] * 0.7 + normalizedValue * 0.3;
}
updateWaveformBars();
animationFrame.value = requestAnimationFrame(() => animateWaveform());
};
const updateWaveformBars = () => {
const bars = document.querySelectorAll('.waveform-bar');
bars.forEach((bar, index) => {
if (index < waveformData.value.length) {
const height = Math.max(6, Math.min(20, 6 + (waveformData.value[index] * 0.14)));
bar.style.height = `${height}px`;
}
});
};
const stopRecording = () => {
if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
mediaRecorder.value.stop();
isRecording.value = false;
if (recordingInterval.value) {
clearInterval(recordingInterval.value);
recordingInterval.value = null;
}
}
};
const cancelRecording = () => {
if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
mediaRecorder.value.onstop = null;
mediaRecorder.value.stop();
}
stopAudioAnalysis();
if (audioStream.value) {
audioStream.value.getTracks().forEach(track => track.stop());
audioStream.value = null;
}
if (recordingInterval.value) {
clearInterval(recordingInterval.value);
recordingInterval.value = null;
}
mediaRecorder.value = null;
isRecording.value = false;
voiceMessage.value = null;
recordingDuration.value = 0;
recordingStartTime.value = null;
waveformData.value = new Array(50).fill(0);
audioChunks.value = [];
};
const stopAudioAnalysis = () => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
animationFrame.value = null;
}
if (audioContext.value && audioContext.value.state !== 'closed') {
audioContext.value.close();
audioContext.value = null;
}
if (audioStream.value) {
audioStream.value.getTracks().forEach(track => track.stop());
audioStream.value = null;
}
};
const sendVoiceMessage = async () => {
if (!voiceMessage.value) return;
isQuickRepliesVisible.value = false;
isRequestCancelled.value = false;
if (isInputCentered.value) isInputCentered.value = false;
messageQueue.value.push({
text: "Voice message sent",
sender: "user",
isVoiceMessage: true,
voiceBlob: voiceMessage.value,
duration: recordingDuration.value
});
const voiceBlob = voiceMessage.value;
const duration = recordingDuration.value;
voiceMessage.value = null;
recordingDuration.value = 0;
await nextTick();
scrollToBottom();
if (!isProcessingQueue.value && messageQueue.value.length > 0) {
processMessageQueue();
}
isBotTyping.value = true;
abortController.value = new AbortController();
try {
const formData = new FormData();
formData.append('voice', voiceBlob, 'voice_message.wav');
const response = await fetch("/dev/voice", {
method: "POST",
body: formData,
signal: abortController.value.signal,
});
if (response.ok) {
const result = await response.json();
const responseText = result.response || result.message || result;
const cleanText = cleanHtmlResponse(responseText);
messageQueue.value.push({ text: cleanText, sender: "bot" });
} else {
messageQueue.value.push({
text: "خطا در پردازش پیام صوتی. لطفاً دوباره امتحان کنید.",
sender: "bot",
});
}
} catch (error) {
console.error("Voice API Error:", error);
if (error.name !== "AbortError") {
messageQueue.value.push({
text: "خطا در ارسال پیام صوتی. لطفاً اتصال را چک کنید.",
sender: "bot",
});
}
} finally {
isBotTyping.value = false;
}
await nextTick();
scrollToBottom();
};
const isVoiceRecordingAvailable = computed(() => {
return !isInputDisabled.value && !isConfirmationActive.value && !activeForm.value && !activeMultiForm.value;
});
// Suggestion database
const suggestionDatabase = {
پروژه: ["New Project ایجاد کنم", "پروژه وب سایت", "پروژه اپلیکیشن موبایل", "پروژه سیستم مدیریت", "پروژه فروشگاه آنلاین", "پروژه های انجام شده", "پروژه در حال اجرا"],
project: ["create a new project", "project management system", "project timeline", "project budget estimation", "project requirements", "project status update", "project completion"],
قیمت: ["قیمت پروژه وب سایت", "قیمت اپلیکیشن موبایل", "قیمت سیستم مدیریت", "قیمت فروشگاه آنلاین", "قیمت گذاری پروژه ها", "قیمت نگهداری و پشتیبانی"],
price: ["pricing for website", "pricing for mobile app", "pricing for management system", "pricing plans", "price calculator", "price comparison"],
"وب سایت": ["وب سایت شرکتی", "وب سایت فروشگاهی", "وب سایت شخصی", "وب سایت خبری", "وب سایت آموزشی", "وب سایت رستوران"],
website: ["website design", "website development", "website maintenance", "website optimization", "website hosting", "website security"],
اپلیکیشن: ["اپلیکیشن موبایل", "اپلیکیشن اندروید", "اپلیکیشن آیفون", "اپلیکیشن تحت وب", "اپلیکیشن فروشگاهی", "اپلیکیشن بانکی"],
app: ["mobile app development", "android app", "ios app", "web app", "app store optimization", "app maintenance"],
پشتیبانی: ["پشتیبانی فنی", "پشتیبانی 24 ساعته", "پشتیبانی پروژه", "پشتیبانی تلفنی", "پشتیبانی آنلاین", "پشتیبانی و نگهداری"],
support: ["technical support", "24/7 support", "customer support", "project support", "maintenance support", "support ticket"]
};
// Forms configuration
const forms = {
subscription: {
id: "subscription", title: "Subscription Confirmation",
question: "Would you like to subscribe to our newsletter?",
options: ["Yes", "No"],
callback: (response) => response === "Yes"
? "Great! You've been subscribed to our newsletter. You'll receive updates on our latest features and promotions."
: "No problem. You can always subscribe later if you change your mind."
},
feedback: {
id: "feedback", title: "Service Feedback",
question: "Was this information helpful?", options: ["Yes", "No"],
callback: (response) => response === "Yes"
? "Thank you for your positive feedback! We're glad we could help."
: "We're sorry to hear that. How can we improve our service?"
},
demo: {
id: "demo", title: "Demo Request",
question: "Would you like to schedule a product demo?", options: ["Yes", "No"],
callback: (response) => response === "Yes"
? "Excellent! Please provide your preferred date and time, and our team will reach out to confirm your demo session."
: "No problem. You can request a demo anytime you're ready to explore our product further."
},
newProject: {
id: "newProject", title: "New Project", type: "multi-step", requiresConfirmation: true,
steps: [
{ id: "projectName", title: "Project Name", question: "Enter your project name:", type: "text", required: true },
{ id: "projectType", title: "Project Type", question: "Select your project type:", type: "options", options: ["Website", "Mobile App", "Management System", "Online Store", "Other"] },
{ id: "budget", title: "Project Budget", question: "What is the estimated budget for the project?", type: "options", options: ["Less than 10M", "10M50M", "50M100M", "More than 100M"] },
{ id: "timeline", title: "Project Timeline", question: "What is the desired duration to complete the project?", type: "options", options: ["Less than 1 month", "13 months", "36 months", "More than 6 months"] },
{ id: "priority", title: "Project Priority", question: "What is the priority level of this project?", type: "options", options: ["Low", "Medium", "High", "Urgent"] },
{ id: "features", title: "Required Features", question: "Which features are important to you?", type: "options", options: ["User Authentication", "Admin Panel", "Online Payment", "Chat & Messaging", "Reporting", "API"] }
],
callback: (data) => {
const summary = `✅ New project submitted!\n\n📋 Project Summary:\n• Name: ${data.projectName}\n• Type: ${data.projectType}\n• Budget: ${data.budget}\n• Timeline: ${data.timeline}\n• Priority: ${data.priority}\n• Features: ${Array.isArray(data.features) ? data.features.join(", ") : data.features}${data.description ? `\n• Notes: ${data.description}` : ""}`;
return summary.trim();
}
},
resourceManagement: {
id: "resourceManagement", title: "Resource Management", type: "multi-step", requiresConfirmation: false,
steps: [
{ id: "resourceType", title: "Resource Type", question: "What type of resource do you want to manage?", type: "options", options: ["Human Resources", "Financial Resources", "Material Resources", "Technology Resources", "Information Resources"] },
{ id: "actionType", title: "Action Type", question: "What would you like to do?", type: "options", options: ["Analyze", "Update"] },
{ id: "analysisType", title: "Analysis Type", question: "What type of analysis would you like to perform?", type: "options", options: ["General Analysis", "Custom Analysis"], condition: (data) => data.actionType === "Analyze" },
{ id: "updateDetails", title: "Update Details", question: "Please provide details for the resource update:", type: "text", required: true, condition: (data) => data.actionType === "Update" }
],
callback: (data) => {
if (data.actionType === "Analyze") {
return `✅ Resource Analysis Complete!\n\n📊 Analysis Summary:\n• Resource Type: ${data.resourceType}\n• Management Scope: ${data.managementScope}\n• Analysis Type: ${data.analysisType}\n\n${data.analysisType === "General Analysis" ? "📋 General analysis has been performed on your selected resources." : "🔍 Custom analysis has been initiated based on your specific requirements."}`.trim();
} else {
return `✅ Resource Update Submitted!\n\n📝 Update Summary:\n• Resource Type: ${data.resourceType}\n• Management Scope: ${data.managementScope}\n• Update Details: ${data.updateDetails}\n\nYour resource update request has been processed successfully.`.trim();
}
}
}
};
// Computed properties
const isQuickRepliesVisible = computed(() => {
if (activeMultiForm.value !== null) return false;
if (activeForm.value !== null) return false;
if (isConfirmationActive.value) return false;
if (isWebSearchEnabled.value) return false;
if (pendingConfirmationFormId.value !== null) return false;
return true;
});
const isConfirmationActive = computed(() => showConfirmation.value || showStepSelection.value);
const isInputDisabled = computed(() => {
if (isConfirmationActive.value) return false;
if (activeForm.value) return true;
if (activeMultiForm.value && getCurrentStepHasOptions()) return true;
return false;
});
const activeModelColor = computed(() => {
const selectedModel = MODELS.value.find(m => m.identifier === selectedModelIdentifier.value);
return selectedModel ? selectedModel.color : "primary";
});
const activeModelBgColor = computed(() => {
const selectedModel = MODELS.value.find(m => m.identifier === selectedModelIdentifier.value);
return selectedModel ? selectedModel.bgColor : "primary-lighten-5";
});
const selectedModelName = computed(() => {
if (!selectedModelIdentifier.value) return "Select Model";
const model = MODELS.value.find(m => m.identifier === selectedModelIdentifier.value);
return model ? model.name : "Select Model";
});
const toggleWebSearch = () => {
isWebSearchEnabled.value = !isWebSearchEnabled.value;
};
// Utility functions
const getCurrentStepHasOptions = () => {
if (!activeMultiForm.value) return false;
const form = forms[activeMultiForm.value];
const currentStep = form.steps[currentFormStep.value];
return currentStep.type === "options" || currentStep.type === "multiple";
};
const cleanHtmlResponse = (htmlString) => {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
return doc.body.textContent || doc.body.innerText || '';
};
const scrollToBottom = async () => {
await nextTick();
const ps = scrollEl.value;
if (!ps) return;
const el = ps.$el || ps;
if (el) {
el.scrollTop = el.scrollHeight;
ps.update?.();
setTimeout(() => { el.scrollTop = el.scrollHeight; }, 50);
}
};
const findNextValidStep = (form, currentStepIndex, formData) => {
for (let i = currentStepIndex + 1; i < form.steps.length; i++) {
const step = form.steps[i];
if (!step.condition || step.condition(formData)) {
return { step, index: i };
}
}
return null;
};
const rtlRegex = /[\u0600-\u06FF\u0750-\u077F]/;
function getDir(str = "") {
return rtlRegex.test(str) ? "rtl" : "ltr";
}
const getSuggestions = (input) => {
if (!input || input.length < 2) return [];
const inputLower = input.toLowerCase().trim();
const matchingSuggestions = [];
// Search in keys
Object.keys(suggestionDatabase).forEach(key => {
if (key.toLowerCase().includes(inputLower)) {
matchingSuggestions.push(...suggestionDatabase[key]);
}
});
// Search in values
Object.values(suggestionDatabase).forEach(suggestions => {
suggestions.forEach(suggestion => {
if (suggestion.toLowerCase().includes(inputLower) && !matchingSuggestions.includes(suggestion)) {
matchingSuggestions.push(suggestion);
}
});
});
return matchingSuggestions.slice(0, 6);
};
// File handling functions
const validateFile = (file) => {
if (file.size > MAX_FILE_SIZE) {
return `File ${file.name} is too large. Maximum size is 5MB.`;
}
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
return `File type ${file.type} is not allowed.`;
}
return null;
};
const getFileIcon = (fileType) => {
if (fileType.startsWith("image/")) return "tabler-photo";
if (fileType.includes("pdf")) return "tabler-file-type-pdf";
if (fileType.includes("word")) return "tabler-file-type-doc";
if (fileType.includes("text")) return "tabler-file-text";
return "tabler-file";
};
const getFileSize = (size) => {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
};
// Message queue processing
const processMessageQueue = async () => {
if (isProcessingQueue.value || messageQueue.value.length === 0) return;
isProcessingQueue.value = true;
while (messageQueue.value.length > 0 && !isRequestCancelled.value) {
const nextMessage = messageQueue.value.shift();
if (isRequestCancelled.value) break;
messages.value.push({ ...nextMessage, isAnimating: true });
await nextTick();
scrollToBottom();
await new Promise(resolve => setTimeout(resolve, 300));
if (!isRequestCancelled.value) {
const lastIndex = messages.value.length - 1;
messages.value[lastIndex] = { ...messages.value[lastIndex], isAnimating: false };
}
}
isProcessingQueue.value = false;
};
// Bot reply logic
const getBotReply = (text) => {
console.log("getBotReply called with:", text);
if (activeForm.value !== null) {
return "Please respond to the current question first.";
}
const lower = text.toLowerCase();
if (lower === "/cancel") {
activeForm.value = null;
activeMultiForm.value = null;
return "The form has been cancelled";
}
// Resource Management
if (text === "Resource Management" || lower === "resource management" || lower === "منابع" || lower === "مدیریت منابع") {
setTimeout(() => startMultiForm("resourceManagement"), 100);
return "Starting Resource Management form...";
}
// New Project
if (text === "New Project" || text === "New Project ایجاد کنم" || lower === "new project" || lower === "نیو پروژکت" || lower === "پروژه جدید") {
setTimeout(() => startMultiForm("newProject"), 100);
return "Starting New Project form...";
}
// Form triggers
const formTriggers = {
subscription: ["subscribe", "newsletter subscription"],
feedback: ["feedback", "rate service"],
demo: ["demo", "request demo"]
};
for (const [formId, triggers] of Object.entries(formTriggers)) {
if (triggers.includes(text)) {
return { type: "form", formId };
}
}
// Commands
const commandResponses = {
"/start": "Welcome! I'm ready to help you. 🎉",
"/help": "Here's a list of things I can do... 🛠️",
"/pricing": "Our pricing starts at $10/month. Would you like more details? 💸",
"/faq": "Check out our FAQ page for common questions. ❓"
};
return commandResponses[text] || null;
};
// Form handling
const handleFormResponse = (userResponse) => {
if (!activeForm.value) return "Sorry, there was an error processing your response.";
const form = forms[activeForm.value];
const normalizedResponse = form.options.find(option => option.toLowerCase() === userResponse.toLowerCase());
if (!normalizedResponse && !["yes", "no"].includes(userResponse.toLowerCase())) {
return "Please respond with one of the provided options.";
}
formResponses.value[form.id] = normalizedResponse || (userResponse.toLowerCase() === "yes" ? "Yes" : "No");
const response = form.callback(formResponses.value[form.id]);
activeForm.value = null;
return response;
};
const handleMultiFormResponse = (userResponse) => {
if (!activeMultiForm.value) {
return { completed: true, message: "خطا در پردازش فرم." };
}
const form = forms[activeMultiForm.value];
const currentStep = form.steps[currentFormStep.value];
multiFormData.value[currentStep.id] = userResponse;
if (pendingConfirmationFormId.value) {
confirmationData.value = { ...confirmationData.value, ...multiFormData.value };
return {
completed: true,
data: confirmationData.value,
showConfirmationAgain: true
};
}
const nextValidStep = findNextValidStep(form, currentFormStep.value, multiFormData.value);
if (nextValidStep) {
currentFormStep.value = nextValidStep.index;
return {
completed: false,
nextStep: {
...nextValidStep.step,
stepNumber: nextValidStep.index + 1,
totalSteps: form.steps.length,
formTitle: form.title
}
};
} else {
const callbackResult = form.callback ? form.callback(multiFormData.value) : null;
return { completed: true, data: multiFormData.value, callbackResult };
}
};
const startMultiForm = (formId) => {
const form = forms[formId];
if (!form) return;
activeMultiForm.value = formId;
currentFormStep.value = 0;
multiFormData.value = {};
let firstValidStepIndex = 0;
for (let i = 0; i < form.steps.length; i++) {
const step = form.steps[i];
if (!step.condition || step.condition(multiFormData.value)) {
firstValidStepIndex = i;
break;
}
}
currentFormStep.value = firstValidStepIndex;
messageQueue.value.push({
sender: "bot",
multiForm: {
...form.steps[firstValidStepIndex],
stepNumber: firstValidStepIndex + 1,
totalSteps: form.steps.length,
formTitle: form.title
},
type: "multi-form"
});
};
// Event handlers
const sendMessage = async () => {
if (isConfirmationActive.value) return;
if (voiceMessage.value) {
await sendVoiceMessage();
return;
}
const userMsg = msg.value.trim();
if (!userMsg && selectedAttachments.value.length === 0) return;
if (projectOptions.value.includes(userMsg)) {
handleProjectSelect(userMsg);
msg.value = "";
return;
}
if (userMsg === "/cancel") {
activeForm.value = null;
activeMultiForm.value = null;
messageQueue.value.push({
text: "The form has been cancelled",
sender: "bot",
});
msg.value = "";
return;
}
isRequestCancelled.value = false;
if (isInputCentered.value) isInputCentered.value = false;
if (selectedAttachments.value.length > 0) {
const fileNames = selectedAttachments.value.map((f) => f.name).join(", ");
messageQueue.value.push({
text: userMsg || `Files sent: ${fileNames}`,
sender: "user",
isAttachment: true,
files: [...selectedAttachments.value],
});
selectedAttachments.value = [];
msg.value = "";
messageQueue.value.push({
text: "فایل‌ها با موفقیت دریافت شد ✅",
sender: "bot",
isSystemMessage: true
});
await nextTick();
scrollToBottom();
return;
} else {
messageQueue.value.push({ text: userMsg, sender: "user" });
}
msg.value = "";
await nextTick();
scrollToBottom();
if (!isProcessingQueue.value && messageQueue.value.length > 0) {
processMessageQueue();
}
if (activeMultiForm.value) {
const formResponse = handleMultiFormResponse(userMsg);
if (formResponse.completed) {
showDataConfirmation(multiFormData.value, activeMultiForm.value);
activeMultiForm.value = null;
currentFormStep.value = 0;
} else if (formResponse.nextStep) {
messageQueue.value.push({
sender: "bot",
multiForm: formResponse.nextStep,
type: "multi-form",
});
}
return;
} else if (activeForm.value) {
const formResponse = handleFormResponse(userMsg);
messageQueue.value.push({ text: formResponse, sender: "bot" });
activeForm.value = null;
return;
} else {
const botReply = getBotReply(userMsg);
if (botReply && typeof botReply === "object") {
if (botReply.type === "multi-form") {
isBotTyping.value = true;
try {
await new Promise((r) => setTimeout(r, 500));
activeMultiForm.value = botReply.formId;
currentFormStep.value = 0;
multiFormData.value = {};
const form = forms[botReply.formId];
messageQueue.value.push({
sender: "bot",
multiForm: {
...form.steps[0],
stepNumber: 1,
totalSteps: form.steps.length,
formTitle: form.title,
},
type: "multi-form",
});
} catch (error) {
console.error("Multi-form setup error:", error);
} finally {
isBotTyping.value = false;
}
} else if (botReply.type === "form") {
activeForm.value = botReply.formId;
const form = forms[botReply.formId];
messageQueue.value.push({
sender: "bot",
text: form.question,
form: {
id: form.id,
title: form.title,
question: form.question,
options: form.options,
},
type: "form",
});
}
return;
} else if (typeof botReply === "string") {
messageQueue.value.push({ text: botReply, sender: "bot" });
return;
} else {
isBotTyping.value = true;
abortController.value = new AbortController();
const SHOW_GENERIC_ERROR_IN_CHAT = true;
try {
console.log("Sending message to server:", userMsg);
const response = await fetch("/dev/chatbot", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ message: userMsg }),
signal: abortController.value.signal,
});
console.log("Response status:", response.status);
console.log("Response ok:", response.ok);
if (response.ok) {
const result = await response.json();
const responseText = result.response || result.message || result;
const cleanText = cleanHtmlResponse(responseText);
messageQueue.value.push({ text: cleanText, sender: "bot" });
} else {
let serverText = "";
try {
serverText = await response.text();
} catch (e) { }
console.error(`Server error (${response.status})`, serverText);
if (SHOW_GENERIC_ERROR_IN_CHAT) {
messageQueue.value.push({
text: "چت‌بات با مشکل روبه‌رو شد. لطفاً دوباره امتحان کن.",
sender: "bot",
});
}
return;
}
} catch (error) {
console.error("Chatbot API Error:", error);
if (error.name === "AbortError") {
console.log("Request was aborted");
return;
}
if (SHOW_GENERIC_ERROR_IN_CHAT) {
messageQueue.value.push({
text: "چت‌بات با مشکل روبه‌رو شد. لطفاً اتصال را چک کن و دوباره تلاش کن.",
sender: "bot",
});
}
return;
} finally {
isBotTyping.value = false;
}
}
}
await nextTick();
scrollToBottom();
};
const handleProjectSelect = (option) => {
selectedProject.value = option;
showProjectMenu.value = false;
if (option === "+ New Project") {
if (isInputCentered.value) isInputCentered.value = false;
activeMultiForm.value = "newProject";
currentFormStep.value = 0;
multiFormData.value = {};
const form = forms.newProject;
messageQueue.value.push({
sender: "bot",
multiForm: {
...form.steps[0],
stepNumber: 1,
totalSteps: form.steps.length,
formTitle: form.title
},
type: "multi-form"
});
}
};
const handleModelChange = (model) => {
selectedModelIdentifier.value = model.identifier;
showModelMenu.value = false;
};
const showDataConfirmation = (data, formId) => {
confirmationData.value = { ...confirmationData.value, ...data };
pendingConfirmationFormId.value = formId;
const form = forms[formId];
const steps = form.steps;
availableSteps.value = steps.map((step, index) => ({ ...step, stepIndex: index }));
messageQueue.value.push({
sender: "bot",
type: "confirmation",
data: confirmationData.value,
formTitle: form.title
});
};
const handleConfirmation = (confirmed, message) => {
if (confirmed) {
showConfirmation.value = false;
showStepSelection.value = false;
isBotTyping.value = true;
message.confirmed = true;
setTimeout(() => {
if (pendingConfirmationFormId.value && forms[pendingConfirmationFormId.value]) {
if (pendingConfirmationFormId.value === "newProject") {
const name = confirmationData.value.projectName;
if (!projectOptions.value.includes(name)) {
const index = projectOptions.value.indexOf("+ New Project");
projectOptions.value.splice(index, 0, name);
const saved = projectOptions.value.filter(p => p !== "+ New Project");
localStorage.setItem("projectOptions", JSON.stringify(saved));
}
selectedProject.value = name;
console.log("✅ Project Data:", JSON.stringify(confirmationData.value, null, 2));
}
const form = forms[pendingConfirmationFormId.value];
const finalMessage = form.callback(confirmationData.value);
messageQueue.value.push({ text: finalMessage, sender: "bot" });
}
isBotTyping.value = false;
confirmationData.value = {};
pendingConfirmationFormId.value = null;
multiFormData.value = {};
activeMultiForm.value = null;
currentFormStep.value = 0;
}, 800);
} else {
showConfirmation.value = false;
showStepSelection.value = true;
messageQueue.value.push({
sender: "bot",
type: "step-selection",
steps: availableSteps.value,
formTitle: pendingConfirmationFormId.value && forms[pendingConfirmationFormId.value]
? forms[pendingConfirmationFormId.value].title
: "Unknown Form"
});
}
};
const handleStepSelection = (stepIndex) => {
const formId = pendingConfirmationFormId.value;
if (!formId || !forms[formId]) return;
activeMultiForm.value = formId;
currentFormStep.value = stepIndex;
showStepSelection.value = false;
messageQueue.value.push({
sender: "bot",
multiForm: {
...forms[formId].steps[stepIndex],
stepNumber: stepIndex + 1,
totalSteps: forms[formId].steps.length,
formTitle: forms[formId].title,
isEditing: true
},
type: "multi-form"
});
};
const handleMultiFormOptionClick = (stepId, option) => {
if (!activeMultiForm.value) return;
messageQueue.value.push({ text: option, sender: "user" });
isBotTyping.value = true;
isRequestCancelled.value = false;
setTimeout(() => {
if (!isRequestCancelled.value) {
isBotTyping.value = false;
const formResponse = handleMultiFormResponse(option);
if (formResponse.completed) {
const form = forms[activeMultiForm.value];
if (formResponse.showConfirmationAgain) {
messageQueue.value.push({
sender: "bot",
type: "confirmation",
data: confirmationData.value,
formTitle: form.title
});
activeMultiForm.value = null;
currentFormStep.value = 0;
} else if (form.requiresConfirmation && !pendingConfirmationFormId.value) {
showDataConfirmation(multiFormData.value, activeMultiForm.value);
} else if (formResponse.callbackResult) {
messageQueue.value.push({ text: formResponse.callbackResult, sender: "bot" });
resetFormStates();
} else {
resetFormStates();
}
} else if (formResponse.nextStep) {
messageQueue.value.push({
sender: "bot",
multiForm: formResponse.nextStep,
type: "multi-form"
});
}
}
}, 1000);
};
const handleFormOptionClick = (formId, option) => {
if (!forms[formId] || answeredForms.value.has(formId)) return;
answeredForms.value.add(formId);
formResponses.value[formId] = option;
messageQueue.value.push({ text: option, sender: "user" });
isBotTyping.value = true;
setTimeout(() => {
isBotTyping.value = false;
const response = forms[formId].callback(option);
messageQueue.value.push({ text: response, sender: "bot" });
activeForm.value = null;
}, 1000);
};
// Utility functions
const resetFormStates = () => {
activeMultiForm.value = null;
currentFormStep.value = 0;
multiFormData.value = {};
};
const selectCommand = (command) => {
msg.value = command;
showCommandList.value = false;
sendMessage();
};
const selectQuickReply = (text) => {
if (activeMultiForm.value !== null) return;
if (activeForm.value !== null) return;
if (isConfirmationActive.value) return;
const clickedButton = event?.target?.closest('.quick-reply-transparent');
if (clickedButton) {
clickedButton.style.transform = 'scale(0.95)';
setTimeout(() => {
clickedButton.style.transform = '';
}, 150);
}
msg.value = text;
sendMessage();
};
const selectSuggestion = (suggestion) => {
msg.value = suggestion;
showSuggestions.value = false;
selectedSuggestionIndex.value = -1;
nextTick(() => {
refInputEl.value?.focus();
});
};
const handleStop = () => {
isRequestCancelled.value = true;
isBotTyping.value = false;
isProcessingQueue.value = false;
if (abortController.value) {
abortController.value.abort();
abortController.value = null;
}
messageQueue.value = [];
messages.value.push({
text: "Request stopped.",
sender: "bot",
isSystemMessage: true
});
nextTick(() => {
scrollToBottom();
});
};
const cancelForm = () => {
const lastFormIndex = messages.value.findLastIndex(
msg => msg.form || msg.multiForm || msg.type === "confirmation" || msg.type === "step-selection"
);
if (lastFormIndex !== -1) {
messages.value[lastFormIndex] = {
...messages.value[lastFormIndex],
isDeleting: true
};
setTimeout(() => {
messages.value.splice(lastFormIndex, 1);
resetAllFormStates();
messageQueue.value.push({
text: "Form canceled",
sender: "bot",
isSystemMessage: true
});
}, 300);
} else {
resetAllFormStates();
messageQueue.value.push({
text: "Form canceled",
sender: "bot",
isSystemMessage: true
});
}
};
const resetAllFormStates = () => {
activeForm.value = null;
activeMultiForm.value = null;
confirmationData.value = {};
pendingConfirmationFormId.value = null;
currentFormStep.value = 0;
multiFormData.value = {};
};
// File handling
const openUploadModal = () => {
isUploadModalOpen.value = true;
fileError.value = "";
};
const closeUploadModal = () => {
isUploadModalOpen.value = false;
fileError.value = "";
};
const triggerFileInput = () => {
fileInputRef.value.click();
};
const handleFileUpload = (event) => {
fileError.value = "";
const files = event.target.files;
if (!files || !files.length) return;
if (selectedAttachments.value.length + files.length > MAX_FILES) {
fileError.value = `You can upload maximum ${MAX_FILES} files at once.`;
return;
}
for (const file of files) {
const error = validateFile(file);
if (error) {
fileError.value = error;
return;
}
selectedAttachments.value.push(file);
}
event.target.value = "";
};
const removeAttachment = (index) => {
selectedAttachments.value = selectedAttachments.value.filter((_, i) => i !== index);
};
// Input handling
const handleCompositionStart = () => {
isComposing.value = true;
};
const handleCompositionEnd = () => {
isComposing.value = false;
};
const handleKeyDown = (e) => {
if (showSuggestions.value && suggestions.value.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
selectedSuggestionIndex.value = Math.min(
selectedSuggestionIndex.value + 1,
suggestions.value.length - 1
);
return;
} else if (e.key === "ArrowUp") {
e.preventDefault();
selectedSuggestionIndex.value = Math.max(selectedSuggestionIndex.value - 1, -1);
return;
} else if (e.key === "Enter" && selectedSuggestionIndex.value >= 0) {
e.preventDefault();
selectSuggestion(suggestions.value[selectedSuggestionIndex.value]);
return;
} else if (e.key === "Escape") {
showSuggestions.value = false;
selectedSuggestionIndex.value = -1;
return;
}
}
if (e.key === "Enter" && !e.shiftKey && !isComposing.value) {
e.preventDefault();
sendMessage();
}
};
// Chat management
const resetChat = () => {
if (isBotTyping.value || isProcessingQueue.value) {
isRequestCancelled.value = true;
isBotTyping.value = false;
if (abortController.value) {
abortController.value.abort();
abortController.value = null;
}
messageQueue.value = [];
isProcessingQueue.value = false;
}
const isChatEmpty = messages.value.length === 0 && !activeForm.value &&
!activeMultiForm.value && selectedAttachments.value.length === 0 && !msg.value.trim();
if (isChatEmpty) {
selectedProject.value = "";
selectedModelIdentifier.value = MODELS.value[0].identifier;
isInputCentered.value = true;
msg.value = "";
return;
}
// Backup current state
chatBackup.value = {
messages: [...messages.value],
selectedProject: selectedProject.value,
selectedModelIdentifier: selectedModelIdentifier.value,
isInputCentered: isInputCentered.value,
msg: msg.value,
activeForm: activeForm.value,
activeMultiForm: activeMultiForm.value,
formResponses: { ...formResponses.value },
multiFormData: { ...multiFormData.value },
confirmationData: { ...confirmationData.value },
currentFormStep: currentFormStep.value,
answeredForms: new Set(answeredForms.value),
pendingConfirmationFormId: pendingConfirmationFormId.value,
messageQueue: [...messageQueue.value],
selectedAttachments: [...selectedAttachments.value],
suggestions: [...suggestions.value],
selectedSuggestionIndex: selectedSuggestionIndex.value,
isBotTyping: isBotTyping.value,
isProcessingQueue: isProcessingQueue.value
};
// Reset all states
resetAllStates();
// Show undo popup
showUndoPopup.value = true;
undoProgress.value = 100;
if (undoInterval.value) clearInterval(undoInterval.value);
undoInterval.value = setInterval(() => {
undoProgress.value = Math.max(0, undoProgress.value - 2);
}, 100);
if (undoTimer.value) clearTimeout(undoTimer.value);
undoTimer.value = setTimeout(() => {
showUndoPopup.value = false;
chatBackup.value = null;
clearInterval(undoInterval.value);
undoInterval.value = null;
}, 5000);
};
const resetAllStates = () => {
messages.value = [];
selectedProject.value = "";
selectedModelIdentifier.value = MODELS.value[0].identifier;
isInputCentered.value = true;
msg.value = "";
activeForm.value = null;
activeMultiForm.value = null;
formResponses.value = {};
multiFormData.value = {};
confirmationData.value = {};
currentFormStep.value = 0;
answeredForms.value = new Set();
pendingConfirmationFormId.value = null;
messageQueue.value = [];
isProcessingQueue.value = false;
isBotTyping.value = false;
selectedAttachments.value = [];
isUploadModalOpen.value = false;
showConfirmation.value = false;
showStepSelection.value = false;
fileError.value = "";
showModelMenu.value = false;
showProjectMenu.value = false;
showCommandList.value = false;
showSuggestions.value = false;
suggestions.value = [];
selectedSuggestionIndex.value = -1;
filteredCommands.value = [];
isComposing.value = false;
availableSteps.value = [];
isRequestCancelled.value = false;
abortController.value = null;
};
const undoReset = () => {
if (!chatBackup.value) return;
// Restore all states from backup
Object.keys(chatBackup.value).forEach(key => {
if (key === 'answeredForms') {
answeredForms.value = chatBackup.value[key];
} else {
eval(`${key}.value = chatBackup.value.${key}`);
}
});
showUndoPopup.value = false;
if (undoTimer.value) {
clearTimeout(undoTimer.value);
undoTimer.value = null;
}
if (undoInterval.value) {
clearInterval(undoInterval.value);
undoInterval.value = null;
}
chatBackup.value = null;
nextTick(() => {
scrollToBottom();
});
};
const closeUndoPopup = () => {
showUndoPopup.value = false;
if (undoTimer.value) {
clearTimeout(undoTimer.value);
undoTimer.value = null;
}
if (undoInterval.value) {
clearInterval(undoInterval.value);
undoInterval.value = null;
}
chatBackup.value = null;
};
// Watchers
watch(messageQueue, (newQueue) => {
if (newQueue.length > 0 && !isProcessingQueue.value) {
processMessageQueue();
}
}, { deep: true });
watch(msg, (val) => {
if (val.startsWith("/")) {
filteredCommands.value = COMMANDS.filter(c => c.startsWith(val));
showCommandList.value = filteredCommands.value.length > 0;
showSuggestions.value = false;
} else {
showCommandList.value = false;
if (val && val.length >= 2) {
suggestions.value = getSuggestions(val);
showSuggestions.value = suggestions.value.length > 0;
selectedSuggestionIndex.value = -1;
} else {
showSuggestions.value = false;
suggestions.value = [];
selectedSuggestionIndex.value = -1;
}
}
});
watch([activeForm, activeMultiForm, isConfirmationActive], ([form, multiForm, confirmation]) => {
if (form !== null || multiForm !== null || confirmation) {
console.log('Quick replies hidden due to active form');
}
if (voiceMessage.value) {
voiceMessage.value = null;
}
});
// Lifecycle
onMounted(() => {
processMessageQueue();
});
// Expose necessary functions and refs for template usage
const quickReplies = QUICK_REPLIES;
const models = MODELS;
const commands = COMMANDS;
</script>
<template>
<VLayout class="chat-app-layout" style="z-index: 0">
<VMain class="chat-content-container">
<PerfectScrollbar ref="scrollEl" tag="ul" :options="{ wheelPropagation: false }"
class="chat-log-scrollable px-4 py-3">
<TransitionGroup name="message">
<li v-for="(message, index) in messages" :key="index" class="my-3 d-flex" :class="[
message.sender === 'user' ? 'justify-end' : 'justify-start',
message.isAnimating ? 'is-animating' : '',
message.sender === 'user' ? 'user-message' : 'bot-message',
message.isSystemMessage ? 'system-message' : '',
]">
<template v-if="message.sender !== 'user'">
<VAvatar size="32" :color="message.isSystemMessage ? 'grey' : activeModelColor" variant="tonal"
class="me-2 message-avatar">
<VIcon :icon="message.isSystemMessage
? 'tabler-info-circle'
: 'tabler-robot'
" />
</VAvatar>
</template>
<div class="pa-3 rounded-lg message-bubble bidi-text" :dir="getDir(message.text)" :class="[
message.sender === 'user'
? 'bg-primary text-white'
: message.isSystemMessage
? 'bg-grey-lighten-3 text-grey-darken-3'
: `bg-${activeModelBgColor} text-white`,
message.isAnimating ? 'animate-message' : '',
message.isVoiceMessage ? 'voice-message-bubble' : '',
]">
<div v-if="message.isVoiceMessage" class="voice-message-display d-flex align-center gap-2">
<VIcon icon="tabler-microphone" size="16" />
<span class="text-caption">Voice message ({{ message.duration }}s)</span>
</div>
<div v-else-if="!message.form && !message.multiForm && !message.type">
{{ message.text }}
</div>
<div v-else-if="message.form" class="form-container">
<div class="form-title font-weight-bold mb-2">
{{ message.form.title }}
</div>
<div class="form-question mb-3">
{{ message.form.question }}
</div>
<div class="form-options d-flex gap-2">
<VBtn v-for="(option, optIndex) in message.form.options" :key="optIndex" :color="activeModelColor"
variant="outlined" size="small" class="form-option-btn"
@click="handleFormOptionClick(message.form.id, option)">
{{ option }}
</VBtn>
</div>
</div>
<div v-else-if="message.multiForm" class="multi-form-container">
<div class="multi-form-header mb-3">
<div class="d-flex justify-space-between align-center mb-2">
<div class="form-title font-weight-bold">
{{ message.multiForm.formTitle }}
</div>
<div class="d-flex align-center">
<VChip :color="activeModelColor" size="small" variant="tonal">
{{ message.multiForm.stepNumber }}/{{
message.multiForm.totalSteps
}}
</VChip>
<VBtn icon size="x-small" @click="cancelForm" class="ms-2">
<VIcon icon="tabler-x" />
</VBtn>
</div>
</div>
<VProgressLinear :model-value="(message.multiForm.stepNumber /
message.multiForm.totalSteps) *
100
" :color="activeModelColor" height="4" rounded class="mb-3"></VProgressLinear>
</div>
<div class="step-title font-weight-medium mb-2">
{{ message.multiForm.title }}
</div>
<div class="form-question mb-3">
{{ message.multiForm.question }}
</div>
<div v-if="message.multiForm.type === 'options'" class="form-options">
<div class="d-flex flex-wrap gap-2">
<VBtn v-for="(option, optIndex) in message.multiForm.options" :key="optIndex"
:color="activeModelColor" variant="outlined" size="small" class="form-option-btn" @click="
handleMultiFormOptionClick(message.multiForm.id, option)
">
{{ option }}
</VBtn>
</div>
</div>
<div v-else-if="message.multiForm.type === 'date'" class="form-date-input">
<VTextField v-model="tempDateValue" :label="message.multiForm.question" type="date"
:min="getMinDate(message.multiForm.id)" variant="outlined" :required="message.multiForm.required"
@keyup.enter="handleDateSubmit" class="mb-3" hide-details />
<VBtn :color="activeModelColor" variant="flat" size="small" @click="handleDateSubmit"
:disabled="!tempDateValue" class="mt-2">
Submit
</VBtn>
</div>
</div>
<div v-else-if="message.type === 'confirmation'" class="confirmation-container">
<div class="form-title font-weight-bold mb-3">
{{ message.formTitle }} - Information verification
</div>
<div class="confirmation-data mb-4">
<div v-for="(value, key) in message.data" :key="key" class="data-item mb-2">
<span class="font-weight-medium">{{ key }}:</span>
<span class="ms-2">{{
Array.isArray(value) ? value.join(", ") : value
}}</span>
</div>
</div>
<div v-if="!message.confirmed" class="confirmation-actions d-flex gap-2">
<VBtn :color="activeModelColor" variant="flat" size="small"
@click="handleConfirmation(true, message)">
Confirm and send
</VBtn>
<VBtn :color="activeModelColor" variant="outlined" size="small"
@click="handleConfirmation(false, message)">
Edit information
</VBtn>
</div>
</div>
<div v-else-if="message.type === 'step-selection'" class="step-selection-container">
<div class="form-title font-weight-bold mb-3">
{{ message.formTitle }} - Select step to edit
</div>
<div class="text-body-2 mb-3">
Which stage do you want to return to?
</div>
<div class="steps-list d-flex flex-column gap-2">
<VBtn v-for="(step, stepIndex) in message.steps" :key="stepIndex" :color="activeModelColor"
variant="outlined" size="small" class="text-start justify-start"
@click="handleStepSelection(step.stepIndex)">
{{ step.title }}
</VBtn>
</div>
</div>
<div v-if="message.isAttachment && message.files" class="attachment-preview mt-2">
<div v-for="(file, fileIndex) in message.files" :key="fileIndex" class="d-flex align-center mb-1">
<VIcon :icon="getFileIcon(file.type)" size="small" class="me-1"></VIcon>
<span class="text-caption">{{ file.name }} ({{ getFileSize(file.size) }})</span>
</div>
</div>
</div>
<template v-if="message.sender === 'user'">
<VAvatar size="32" color="grey" variant="tonal" class="ms-2 message-avatar">
<VIcon icon="tabler-user" />
</VAvatar>
</template>
</li>
</TransitionGroup>
<li v-if="isBotTyping" class="my-3 d-flex justify-start typing-indicator-container">
<VAvatar size="32" :color="activeModelColor" variant="tonal" class="me-2 message-avatar">
<VIcon icon="tabler-robot" />
</VAvatar>
<div class="pa-3 rounded-lg message-bubble" :class="`bg-${activeModelBgColor} text-white`"
:style="`--typing-shadow-color: rgba(var(--v-theme-${activeModelColor}), 0.3)`">
<div class="bot-typing-indicator">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
</li>
</PerfectScrollbar>
<div v-if="isInputCentered" class="welcome-message text-center pa-4">
<h2 class="text-h3 mb-2">Welcome to <b>MegaTegra</b> Chatbot!</h2>
<p class="text-body-1">Type a message below to get started.</p>
</div>
<div class="input-wrapper" :class="{
'centered-input': isInputCentered,
'bottom-input': !isInputCentered,
}">
<div class="chat-input-container rounded-3xl pa-3 mb-2" :class="{
'pt-0': selectedAttachments.length === 0
}" :style="`border: 1px solid rgba(var(--v-theme-${activeModelColor}), 0.5);
--active-model-color: var(--v-theme-${activeModelColor})`">
<div v-if="selectedAttachments.length > 0" class="d-flex flex-wrap gap-2 mt-3 mb-2 attachment-chip-container">
<VChip v-for="(file, index) in selectedAttachments" :key="index" closable
@click:close="removeAttachment(index)" color="grey-lighten-3" class="align-center attachment-chip">
<VIcon start :icon="getFileIcon(file.type)" size="small"></VIcon>
<span class="attachment-name">{{ file.name }}</span>
<span class="attachment-size">({{ getFileSize(file.size) }})</span>
</VChip>
</div>
<div v-if="isRecording" class="voice-recording-ui">
<div class="waveform-visualizer">
<canvas ref="waveformCanvas"></canvas>
</div>
<div class="voice-controls-wrapper">
<div class="recording-time">{{ formatRecordingTime(recordingDuration) }}</div>
<div class="voice-controls">
<VBtn icon size="small" color="error" variant="flat" @click="cancelRecording" class="voice-btn">
<VIcon icon="tabler-x" size="18" />
</VBtn>
<VBtn icon size="small" :color="activeModelColor" variant="flat" @click="stopRecording"
class="voice-btn">
<VIcon icon="tabler-check" size="18" />
</VBtn>
</div>
</div>
</div>
<div v-else-if="voiceMessage" class="voice-preview-ui">
<div class="voice-preview-content">
<VIcon icon="tabler-microphone" size="18" :color="activeModelColor" />
<span class="voice-preview-text">Voice message ready</span>
<span class="voice-duration">({{ recordingDuration }}s)</span>
</div>
<div class="voice-controls">
<VBtn icon size="small" color="error" variant="flat" @click="cancelRecording" class="voice-btn">
<VIcon icon="tabler-x" size="18" />
</VBtn>
<VBtn icon size="small" :color="activeModelColor" variant="flat" @click="sendMessage" class="voice-btn">
<VIcon icon="tabler-send" size="18" />
</VBtn>
</div>
</div>
<div v-else class="normal-input-ui">
<div class="position-relative">
<VTextarea v-model="msg" auto-grow rows="1" row-height="30"
:disabled="isInputDisabled || isConfirmationActive" hide-details variant="plain" persistent-placeholder
density="comfortable" class="chat-textarea" placeholder="Type your message..." no-resize
@keydown="handleKeyDown" @compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"></VTextarea>
</div>
<div v-if="showSuggestions" class="suggestions-dropdown rounded pa-2">
<div class="suggestions-header text-caption text-grey-darken-1 mb-1 px-2">
Suggestions
</div>
<div v-for="(suggestion, index) in suggestions" :key="index"
class="suggestion-item pa-2 rounded d-flex align-center" :class="{
'suggestion-selected': selectedSuggestionIndex === index,
}" @click="selectSuggestion(suggestion)" @mouseenter="selectedSuggestionIndex = index">
<VIcon icon="tabler-search" size="16" class="me-2 text-grey-darken-1"></VIcon>
<span class="suggestion-text">{{ suggestion }}</span>
</div>
</div>
<div v-if="showCommandList" class="command-dropdown rounded pa-2">
<div v-for="(cmd, i) in filteredCommands" :key="i" class="command-item pa-2 rounded"
@click="selectCommand(cmd)">
{{ cmd }}
</div>
</div>
<div class="d-flex align-center justify-space-between mt-1 input-toolbar">
<div class="d-flex gap-2 align-center toolbar-buttons">
<VBtn icon variant="text" density="comfortable"
color="rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity))" @click="resetChat">
<VIcon icon="tabler-message-circle-plus" />
</VBtn>
<VBtn icon variant="text" density="comfortable" :color="activeModelColor" @click="triggerFileInput">
<VIcon icon="tabler-paperclip"></VIcon>
<input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFileUpload" />
</VBtn>
<VBtn icon variant="text" density="comfortable" :color="activeModelColor" @click="startRecording"
:disabled="!isVoiceRecordingAvailable">
<VIcon icon="tabler-microphone" />
</VBtn>
<VBtn :color="isWebSearchEnabled ? 'primary' : 'grey-darken-1'"
:variant="isWebSearchEnabled ? 'tonal' : 'text'" :elevation="isWebSearchEnabled ? 8 : 2"
density="comfortable" class="text-none web-search-btn"
:class="{ 'web-search-active': isWebSearchEnabled }" @click="toggleWebSearch">
<VIcon icon="tabler-analyze" class="me-1 web-icon"
:class="{ 'web-icon-active': isWebSearchEnabled }" />
<span class="d-none d-sm-block web-text">Scenario</span>
</VBtn>
<VMenu v-model="showProjectMenu" location="top">
<template #activator="{ props }">
<VBtn v-bind="props" :color="selectedProject === '+ New Project'
? 'primary'
: selectedProject
? 'white'
: 'secondary'
" variant="text" density="comfortable" class="text-none">
<span class="d-none d-sm-block">
{{ selectedProject || "Project Menu" }}
</span>
<span class="d-block d-sm-none"> Project </span>
<VIcon icon="tabler-chevron-down" class="ms-1" />
</VBtn>
</template>
<VList density="compact" max-height="200">
<VListItem v-for="option in projectOptions" :key="option" @click="handleProjectSelect(option)"
:class="{
'bg-primary text-white':
option === selectedProject &&
option !== '+ New Project',
}">
<template #prepend v-if="option === selectedProject && option !== '+ New Project'
">
</template>
<VListItemTitle :class="option === '+ New Project' ? 'text-primary' : ''">
{{ option }}
</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</div>
<VBtn v-if="!isBotTyping" icon :color="activeModelColor" @click="sendMessage" :disabled="isInputDisabled ||
(!msg.trim() && selectedAttachments.length === 0 && !voiceMessage)
">
<VIcon icon="tabler-send"></VIcon>
</VBtn>
<VBtn v-else icon :color="activeModelColor" @click="handleStop">
<VIcon icon="tabler-square"></VIcon>
</VBtn>
</div>
</div>
</div>
<Transition name="quick-replies-fade" mode="out-in" appear>
<div v-if="
isQuickRepliesVisible &&
!activeForm &&
!activeMultiForm &&
!isConfirmationActive &&
!isRecording &&
!voiceMessage &&
!isBotTyping
" class="quick-replies-container" key="quick-replies">
<TransitionGroup name="quick-reply" appear>
<div v-for="(reply, index) in quickReplies" :key="reply" class="quick-reply-item"
:style="{ transitionDelay: `${index * 50}ms` }">
<VBtn class="quick-reply-transparent" variant="text" size="small" :style="`border: 1px solid rgba(var(--v-theme-${activeModelColor}), 0.5) !important;
--hover-bg-color: rgba(var(--v-theme-${activeModelColor}), 0.1);
--hover-border-color: rgba(var(--v-theme-${activeModelColor}), 0.8)`" @click="selectQuickReply(reply)">
{{ reply }}
</VBtn>
</div>
</TransitionGroup>
</div>
</Transition>
</div>
</VMain>
<VDialog v-model="isUploadModalOpen" max-width="500">
<VCard>
<VCardTitle class="pb-2 pt-4">
<span class="text-h5">Upload Files</span>
</VCardTitle>
<VCardText>
<div class="text-subtitle-2 mb-4">
Select files to upload (Max 5 files, 5MB each)
</div>
<div class="upload-area pa-6 border-dashed rounded d-flex flex-column align-center justify-center"
@click="triggerFileInput" @dragover.prevent @drop.prevent="
(e) =>
handleFileUpload({ target: { files: e.dataTransfer.files } })
">
<VIcon :icon="'tabler-upload'" :size="36" :color="activeModelColor" class="mb-3"></VIcon>
<div class="text-body-1 mb-1">
Drag files here or click to upload
</div>
<div class="text-caption text-grey">
Supported formats: Images, PDF, DOC, DOCX, TXT
</div>
<input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFileUpload" />
</div>
<div v-if="fileError" class="text-error mt-2">
{{ fileError }}
</div>
<div v-if="selectedAttachments.length > 0">
<div class="text-subtitle-2 mt-4 mb-2">Selected files:</div>
<VList density="compact" class="bg-grey-lighten-5 rounded">
<VListItem v-for="(file, index) in selectedAttachments" :key="index">
<template v-slot:prepend>
<VIcon :icon="getFileIcon(file.type)"></VIcon>
</template>
<VListItemTitle>{{ file.name }}</VListItemTitle>
<VListItemSubtitle>{{
getFileSize(file.size)
}}</VListItemSubtitle>
<template v-slot:append>
<VBtn icon size="small" variant="text" @click="removeAttachment(index)">
<VIcon icon="tabler-x"></VIcon>
</VBtn>
</template>
</VListItem>
</VList>
</div>
</VCardText>
<VCardActions class="pb-4 px-4">
<VSpacer></VSpacer>
<VBtn color="grey-darken-1" variant="text" @click="closeUploadModal">
Cancel
</VBtn>
<VBtn :color="activeModelColor" variant="tonal" @click="closeUploadModal"
:disabled="selectedAttachments.length === 0">
Done
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<VSnackbar v-model="showUndoPopup" location="top right" :timeout="-1" color="primary" elevation="8" rounded="lg"
class="undo-popup">
<div class="d-flex align-center justify-space-between" style="width: 100%">
<div class="d-flex align-center">
<span class="text-body-2">Click Undo for return</span>
</div>
<div class="d-flex align-center ms-3">
<VBtn variant="text" color="white" size="small" @click="undoReset" class="me-2">
<VIcon size="16">tabler-arrow-back-up</VIcon>
<span class="ms-1">Undo</span>
</VBtn>
<VBtn variant="text" color="white" size="small" @click="closeUndoPopup" icon>
<VIcon size="16">tabler-x</VIcon>
</VBtn>
</div>
</div>
<VProgressLinear :model-value="undoProgress" height="4" color="white" rounded class="mt-2" />
</VSnackbar>
</VLayout>
</template>
<style lang="scss">
@import '../../../styles/chat-app.scss';
</style>