1959 lines
67 KiB
Vue
1959 lines
67 KiB
Vue
<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", "10M–50M", "50M–100M", "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", "1–3 months", "3–6 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> |