(function () { if (window.VCAIBotWidget) return; function create(tag, styles) { const el = document.createElement(tag); Object.assign(el.style, styles || {}); return el; } window.VCAIBotWidget = { mount: function (config) { const baseUrl = (config.baseUrl || '').replace(/\/$/, ''); const token = config.token || ''; const contentLanguage = (config.language || navigator.language || 'en').toString(); const root = create('div', { position: 'fixed', bottom: '24px', right: '24px', width: '340px', background: '#111827', color: '#fff', borderRadius: '14px', boxShadow: '0 10px 30px rgba(0,0,0,.3)', zIndex: '99999', overflow: 'hidden', fontFamily: 'Inter,system-ui,sans-serif' }); const header = create('div', {padding: '12px 14px', background: '#1f2937', fontWeight: '600'}); header.textContent = 'VC AI Bot'; const log = create('div', {height: '320px', overflowY: 'auto', padding: '12px', fontSize: '14px'}); const row = create('div', {display: 'flex', gap: '8px', padding: '10px', borderTop: '1px solid #374151'}); const recordIndicator = create('div', { width: '10px', height: '10px', borderRadius: '999px', background: '#ef4444', alignSelf: 'center', marginRight: '2px', display: 'none', opacity: '0.9' }); const input = create('input', {flex: '1', borderRadius: '10px', border: 'none', padding: '10px'}); const send = create('button', {border: 'none', borderRadius: '10px', background: '#2563eb', color: '#fff', padding: '10px 12px', cursor: 'pointer'}); const attach = create('button', {border: 'none', borderRadius: '10px', background: '#334155', color: '#fff', padding: '10px 12px', cursor: 'pointer'}); const mic = create('button', {border: 'none', borderRadius: '10px', background: '#059669', color: '#fff', padding: '10px 12px', cursor: 'pointer'}); const fileInput = create('input', {display: 'none'}); fileInput.type = 'file'; fileInput.accept = '.pdf,.txt,application/pdf,text/plain'; attach.textContent = '📎'; send.textContent = 'Send'; mic.textContent = '🎤'; function add(author, text) { const p = create('div', {marginBottom: '8px', lineHeight: '1.35'}); p.innerHTML = '' + author + ': ' + text; log.appendChild(p); log.scrollTop = log.scrollHeight; } let sessionId = null; let firstUserMessage = true; let isRecording = false; let activeRecorder = null; let activeStream = null; let pulseInterval = null; let chatLocked = false; function disableAttach() { attach.disabled = true; attach.style.opacity = '0.5'; attach.style.cursor = 'not-allowed'; } function lockChat(reasonText) { chatLocked = true; setRecordingUi(false); input.disabled = true; send.disabled = true; mic.disabled = true; attach.disabled = true; send.style.opacity = '0.5'; mic.style.opacity = '0.5'; attach.style.opacity = '0.5'; send.style.cursor = 'not-allowed'; mic.style.cursor = 'not-allowed'; attach.style.cursor = 'not-allowed'; input.placeholder = reasonText || 'Чат завершён'; } function isCompletedResponse(status, botText) { const normalizedStatus = String(status || '').toLowerCase(); if (normalizedStatus === 'completed') return true; const text = String(botText || '').toLowerCase(); return text.indexOf('интервью завершено') !== -1 || text.indexOf('interview finished') !== -1 || text.indexOf('thank you! the checklist is complete') !== -1; } function applyResponseState(payload, botText) { if (isCompletedResponse(payload && payload.status, botText)) { lockChat('Интервью завершено'); } } function setRecordingUi(recording) { isRecording = recording; mic.textContent = recording ? '⏹' : '🎤'; mic.style.background = recording ? '#dc2626' : '#059669'; input.placeholder = recording ? 'Идёт запись... Нажмите ⏹ для отправки' : ''; recordIndicator.style.display = recording ? 'block' : 'none'; if (pulseInterval) { clearInterval(pulseInterval); pulseInterval = null; } if (recording) { let on = true; pulseInterval = setInterval(function () { recordIndicator.style.opacity = on ? '1' : '0.35'; on = !on; }, 500); } } async function stopRecordingAndSend() { if (!activeRecorder) return; const rec = activeRecorder; activeRecorder = null; await new Promise(function (resolve) { rec.addEventListener('stop', function () { resolve(); }, {once: true}); rec.stop(); }); } async function api(path, options) { const headers = Object.assign({}, (options && options.headers) || {}); headers['Content-Language'] = contentLanguage; if (!options || !options.noJsonContentType) { headers['Content-Type'] = 'application/json'; } if (token) headers['Authorization'] = 'Bearer ' + token; const res = await fetch(baseUrl + path, Object.assign({}, options || {}, {headers})); if (!res.ok) { const txt = await res.text(); throw new Error(txt || ('HTTP ' + res.status)); } return res.json(); } async function speak(text) { if (!text) return; if (!('speechSynthesis' in window)) return; try { function detectLang(input) { return /[А-Яа-яЁё]/.test(input) ? 'ru-RU' : 'en-US'; } const utterance = new SpeechSynthesisUtterance(text); utterance.lang = detectLang(text); utterance.rate = 1; utterance.pitch = 1; const voices = window.speechSynthesis.getVoices(); const matchedVoice = voices.find(function (v) { return (v.lang || '').toLowerCase().startsWith(utterance.lang.toLowerCase().slice(0, 2)); }); if (matchedVoice) { utterance.voice = matchedVoice; } window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance); } catch (e) { /* noop */ } } async function startIfNeeded() { if (sessionId) return; const started = await api('/interviews/start', {method: 'POST'}); sessionId = started.session_id; const greeting = started.greeting || started.greeting_message; if (greeting) { add('Bot', greeting); speak(greeting); } } startIfNeeded().catch(function (e) { add('Error', e.message); }); async function sendMessage() { if (chatLocked) return; const text = (input.value || '').trim(); if (!text) return; input.value = ''; add('You', text); await startIfNeeded(); if (firstUserMessage) { const form = new FormData(); form.append('text', text); const uploaded = await api('/interviews/' + sessionId + '/pitch', { method: 'POST', body: form, noJsonContentType: true }); firstUserMessage = false; add('Bot', uploaded.first_question); speak(uploaded.first_question); applyResponseState(uploaded, uploaded.first_question); return; } const form = new FormData(); form.append('text', text); const answered = await api('/interviews/' + sessionId + '/respond', { method: 'POST', body: form, noJsonContentType: true }); add('Bot', answered.assistant_message); speak(answered.assistant_message); applyResponseState(answered, answered.assistant_message); } async function sendPitchFile(file) { if (chatLocked) return; if (!file) return; await startIfNeeded(); if (!firstUserMessage) { add('Error', 'Файл можно отправить только как первый pitch.'); return; } const form = new FormData(); form.append('file', file, file.name || 'pitch.pdf'); add('You', '📄 ' + (file.name || 'pitch')); const uploaded = await api('/interviews/' + sessionId + '/pitch', { method: 'POST', body: form, noJsonContentType: true }); firstUserMessage = false; disableAttach(); add('Bot', uploaded.first_question); speak(uploaded.first_question); applyResponseState(uploaded, uploaded.first_question); } send.onclick = function () { sendMessage().catch(function (e) { add('Error', e.message); }); }; attach.onclick = function () { fileInput.click(); }; fileInput.onchange = function () { const selected = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null; fileInput.value = ''; sendPitchFile(selected).catch(function (e) { add('Error', e.message); }); }; input.addEventListener('keydown', function (e) { if (e.key === 'Enter') send.onclick(); }); mic.onclick = async function () { if (chatLocked) return; if (isRecording) { stopRecordingAndSend().catch(function () { add('Error', 'Не удалось остановить запись.'); }); return; } try { const stream = await navigator.mediaDevices.getUserMedia({audio: true}); activeStream = stream; const chunks = []; const mimeCandidates = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/mp4' ]; const selectedMimeType = mimeCandidates.find(function (item) { return window.MediaRecorder && typeof MediaRecorder.isTypeSupported === 'function' && MediaRecorder.isTypeSupported(item); }) || ''; const rec = selectedMimeType ? new MediaRecorder(stream, {mimeType: selectedMimeType}) : new MediaRecorder(stream); activeRecorder = rec; rec.ondataavailable = function (e) { if (e.data.size > 0) chunks.push(e.data); }; rec.onstop = async function () { setRecordingUi(false); if (activeStream) { activeStream.getTracks().forEach(function (t) { t.stop(); }); activeStream = null; } const blobType = selectedMimeType || 'audio/webm'; const blob = new Blob(chunks, {type: blobType}); if (!blob.size) { add('Error', 'Не удалось записать аудио. Попробуйте ещё раз.'); return; } let fileName = 'voice.webm'; if (blobType.indexOf('ogg') !== -1) fileName = 'voice.ogg'; if (blobType.indexOf('mp4') !== -1) fileName = 'voice.m4a'; const form = new FormData(); await startIfNeeded(); const fieldName = firstUserMessage ? 'file' : 'voice'; form.append(fieldName, blob, fileName); const headers = {}; headers['Content-Language'] = contentLanguage; if (token) headers['Authorization'] = 'Bearer ' + token; const path = firstUserMessage ? '/interviews/' + sessionId + '/pitch' : '/interviews/' + sessionId + '/respond'; const resp = await fetch(baseUrl + path, {method: 'POST', body: form, headers}); const data = await resp.json(); if (!resp.ok) { add('Error', (data && data.detail) ? data.detail : 'Voice message failed'); return; } if (firstUserMessage) { firstUserMessage = false; add('Bot', data.first_question || ''); speak(data.first_question || ''); applyResponseState(data, data.first_question || ''); } else { add('Bot', data.assistant_message || ''); speak(data.assistant_message || ''); applyResponseState(data, data.assistant_message || ''); } }; rec.start(1000); setRecordingUi(true); } catch (e) { setRecordingUi(false); if (activeStream) { activeStream.getTracks().forEach(function (t) { t.stop(); }); activeStream = null; } add('Error', 'Microphone is unavailable'); } }; row.appendChild(recordIndicator); row.appendChild(input); row.appendChild(attach); row.appendChild(mic); row.appendChild(send); root.appendChild(header); root.appendChild(log); root.appendChild(row); root.appendChild(fileInput); document.body.appendChild(root); } }; })();