Files
panel/resources/js/pages/apps/chat.vue

1959 lines
67 KiB
Vue
Raw Permalink Normal View History

2025-08-04 16:33:07 +03:30
<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);
2025-09-28 13:46:57 +03:30
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;
});
2025-08-04 16:33:07 +03:30
// 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;
2025-08-25 12:19:58 +03:30
if (pendingConfirmationFormId.value !== null) return false;
2025-08-04 16:33:07 +03:30
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;
};
2025-09-28 13:46:57 +03:30
const rtlRegex = /[\u0600-\u06FF\u0750-\u077F]/;
function getDir(str = "") {
return rtlRegex.test(str) ? "rtl" : "ltr";
}
2025-08-04 16:33:07 +03:30
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];
2025-08-25 12:19:58 +03:30
2025-08-04 16:33:07 +03:30
multiFormData.value[currentStep.id] = userResponse;
if (pendingConfirmationFormId.value) {
confirmationData.value = { ...confirmationData.value, ...multiFormData.value };
2025-08-25 12:19:58 +03:30
return {
completed: true,
2025-08-04 16:33:07 +03:30
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;
2025-09-28 13:46:57 +03:30
if (voiceMessage.value) {
await sendVoiceMessage();
return;
}
2025-08-04 16:33:07 +03:30
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;
2025-09-28 13:46:57 +03:30
messageQueue.value.push({
text: "The form has been cancelled",
sender: "bot",
});
2025-08-04 16:33:07 +03:30
msg.value = "";
return;
}
isRequestCancelled.value = false;
if (isInputCentered.value) isInputCentered.value = false;
if (selectedAttachments.value.length > 0) {
2025-09-28 13:46:57 +03:30
const fileNames = selectedAttachments.value.map((f) => f.name).join(", ");
2025-08-04 16:33:07 +03:30
messageQueue.value.push({
text: userMsg || `Files sent: ${fileNames}`,
sender: "user",
isAttachment: true,
2025-09-28 13:46:57 +03:30
files: [...selectedAttachments.value],
2025-08-04 16:33:07 +03:30
});
2025-09-28 13:46:57 +03:30
2025-08-04 16:33:07 +03:30
selectedAttachments.value = [];
2025-09-28 13:46:57 +03:30
msg.value = "";
messageQueue.value.push({
text: "فایل‌ها با موفقیت دریافت شد ✅",
sender: "bot",
isSystemMessage: true
});
await nextTick();
scrollToBottom();
return;
2025-08-04 16:33:07 +03:30
} 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,
2025-09-28 13:46:57 +03:30
type: "multi-form",
2025-08-04 16:33:07 +03:30
});
}
2025-09-28 13:46:57 +03:30
return;
2025-08-04 16:33:07 +03:30
} else if (activeForm.value) {
const formResponse = handleFormResponse(userMsg);
messageQueue.value.push({ text: formResponse, sender: "bot" });
activeForm.value = null;
2025-09-28 13:46:57 +03:30
return;
2025-08-04 16:33:07 +03:30
} else {
const botReply = getBotReply(userMsg);
if (botReply && typeof botReply === "object") {
if (botReply.type === "multi-form") {
isBotTyping.value = true;
2025-09-28 13:46:57 +03:30
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;
}
2025-08-04 16:33:07 +03:30
} 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,
2025-09-28 13:46:57 +03:30
options: form.options,
2025-08-04 16:33:07 +03:30
},
2025-09-28 13:46:57 +03:30
type: "form",
2025-08-04 16:33:07 +03:30
});
}
return;
} else if (typeof botReply === "string") {
messageQueue.value.push({ text: botReply, sender: "bot" });
2025-09-28 13:46:57 +03:30
return;
2025-08-04 16:33:07 +03:30
} else {
isBotTyping.value = true;
abortController.value = new AbortController();
2025-09-28 13:46:57 +03:30
const SHOW_GENERIC_ERROR_IN_CHAT = true;
2025-08-04 16:33:07 +03:30
try {
2025-09-28 13:46:57 +03:30
console.log("Sending message to server:", userMsg);
const response = await fetch("/dev/chatbot", {
2025-08-04 16:33:07 +03:30
method: "POST",
2025-09-28 13:46:57 +03:30
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ message: userMsg }),
signal: abortController.value.signal,
2025-08-04 16:33:07 +03:30
});
2025-09-28 13:46:57 +03:30
console.log("Response status:", response.status);
console.log("Response ok:", response.ok);
2025-08-04 16:33:07 +03:30
if (response.ok) {
2025-09-28 13:46:57 +03:30
const result = await response.json();
const responseText = result.response || result.message || result;
const cleanText = cleanHtmlResponse(responseText);
2025-08-04 16:33:07 +03:30
messageQueue.value.push({ text: cleanText, sender: "bot" });
} else {
2025-09-28 13:46:57 +03:30
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;
2025-08-04 16:33:07 +03:30
}
2025-09-28 13:46:57 +03:30
if (SHOW_GENERIC_ERROR_IN_CHAT) {
messageQueue.value.push({
text: "چت‌بات با مشکل روبه‌رو شد. لطفاً اتصال را چک کن و دوباره تلاش کن.",
sender: "bot",
});
2025-08-04 16:33:07 +03:30
}
2025-09-28 13:46:57 +03:30
return;
} finally {
isBotTyping.value = false;
2025-08-04 16:33:07 +03:30
}
}
}
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;
2025-08-25 12:19:58 +03:30
2025-08-04 16:33:07 +03:30
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];
2025-08-25 12:19:58 +03:30
2025-08-04 16:33:07 +03:30
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');
}
2025-09-28 13:46:57 +03:30
if (voiceMessage.value) {
voiceMessage.value = null;
}
2025-08-04 16:33:07 +03:30
});
// Lifecycle
onMounted(() => {
processMessageQueue();
});
// Expose necessary functions and refs for template usage
const quickReplies = QUICK_REPLIES;
const models = MODELS;
const commands = COMMANDS;
</script>
2025-09-28 13:46:57 +03:30
2025-08-04 16:33:07 +03:30
<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>
2025-09-28 13:46:57 +03:30
<div class="pa-3 rounded-lg message-bubble bidi-text" :dir="getDir(message.text)" :class="[
2025-08-04 16:33:07 +03:30
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' : '',
2025-09-28 13:46:57 +03:30
message.isVoiceMessage ? 'voice-message-bubble' : '',
2025-08-04 16:33:07 +03:30
]">
2025-09-28 13:46:57 +03:30
<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">
2025-08-04 16:33:07 +03:30
{{ 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>
2025-09-28 13:46:57 +03:30
<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>
2025-08-04 16:33:07 +03:30
</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
2025-09-28 13:46:57 +03:30
}}</span>
2025-08-04 16:33:07 +03:30
</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,
}">
2025-09-28 13:46:57 +03:30
<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);
2025-08-04 16:33:07 +03:30
--active-model-color: var(--v-theme-${activeModelColor})`">
2025-09-28 13:46:57 +03:30
2025-08-04 16:33:07 +03:30
<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>
2025-09-28 13:46:57 +03:30
<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>
2025-08-04 16:33:07 +03:30
</div>
2025-09-28 13:46:57 +03:30
<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>
2025-08-04 16:33:07 +03:30
</div>
2025-09-28 13:46:57 +03:30
<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>
2025-08-04 16:33:07 +03:30
</div>
</div>
2025-09-28 13:46:57 +03:30
<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>
2025-08-04 16:33:07 +03:30
</div>
2025-09-28 13:46:57 +03:30
<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>
2025-08-04 16:33:07 +03:30
2025-09-28 13:46:57 +03:30
<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>
2025-08-04 16:33:07 +03:30
2025-09-28 13:46:57 +03:30
<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>
2025-08-04 16:33:07 +03:30
2025-09-28 13:46:57 +03:30
<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>
2025-08-04 16:33:07 +03:30
2025-09-28 13:46:57 +03:30
<VBtn icon variant="text" density="comfortable" :color="activeModelColor" @click="startRecording"
:disabled="!isVoiceRecordingAvailable">
<VIcon icon="tabler-microphone" />
</VBtn>
2025-08-04 16:33:07 +03:30
2025-09-28 13:46:57 +03:30
<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>
2025-08-04 16:33:07 +03:30
2025-09-28 13:46:57 +03:30
<VBtn v-if="!isBotTyping" icon :color="activeModelColor" @click="sendMessage" :disabled="isInputDisabled ||
(!msg.trim() && selectedAttachments.length === 0 && !voiceMessage)
">
<VIcon icon="tabler-send"></VIcon>
</VBtn>
2025-08-04 16:33:07 +03:30
2025-09-28 13:46:57 +03:30
<VBtn v-else icon :color="activeModelColor" @click="handleStop">
<VIcon icon="tabler-square"></VIcon>
</VBtn>
</div>
2025-08-04 16:33:07 +03:30
</div>
</div>
<Transition name="quick-replies-fade" mode="out-in" appear>
2025-09-28 13:46:57 +03:30
<div v-if="
isQuickRepliesVisible &&
!activeForm &&
!activeMultiForm &&
!isConfirmationActive &&
!isRecording &&
!voiceMessage &&
!isBotTyping
" class="quick-replies-container" key="quick-replies">
2025-08-04 16:33:07 +03:30
<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;
2025-09-28 13:46:57 +03:30
--hover-bg-color: rgba(var(--v-theme-${activeModelColor}), 0.1);
--hover-border-color: rgba(var(--v-theme-${activeModelColor}), 0.8)`" @click="selectQuickReply(reply)">
2025-08-04 16:33:07 +03:30
{{ 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)
2025-09-28 13:46:57 +03:30
}}</VListItemSubtitle>
2025-08-04 16:33:07 +03:30
<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>