Files
summary-builder/public/index.html

965 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Summary Builder — Edemium</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#f7f8fb;--white:#fff;--text:#1a1a2e;--sub:#6b7280;
--accent:#2563eb;--accent-light:#eff6ff;--accent-hover:#1d4ed8;
--border:#e5e7eb;--radius:12px;--shadow:0 1px 3px rgba(0,0,0,.06);
--green:#10b981;--green-bg:#ecfdf5;--red:#ef4444;
}
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);line-height:1.6;min-height:100vh;overflow:hidden}
html{overflow:hidden;height:100%}
.auth-screen{display:none;align-items:center;justify-content:center;min-height:100vh;padding:20px}
.auth-card{background:var(--white);border:1px solid var(--border);border-radius:16px;padding:48px 40px;max-width:400px;width:100%;text-align:center;box-shadow:0 4px 24px rgba(0,0,0,.06)}
.auth-logo{font-size:28px;font-weight:800;color:var(--accent);margin-bottom:4px}
.auth-sub{font-size:14px;color:var(--sub);margin-bottom:32px}
.auth-input{width:100%;padding:14px 16px;border:1px solid var(--border);border-radius:var(--radius);font-size:16px;font-family:inherit;outline:none;transition:all .2s;text-align:center;letter-spacing:1px}
.auth-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.12)}
.auth-input.error{border-color:var(--red);box-shadow:0 0 0 3px rgba(239,68,68,.12)}
.auth-btn{width:100%;padding:14px;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);font-size:16px;font-weight:600;cursor:pointer;margin-top:16px;font-family:inherit;transition:all .2s}
.auth-btn:hover{background:var(--accent-hover);transform:translateY(-1px)}
.auth-btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
.auth-error{color:var(--red);font-size:13px;margin-top:12px;display:none}
.app-screen{display:none;height:100vh;flex-direction:column}
.topbar{display:flex;align-items:center;justify-content:space-between;padding:12px 24px;background:var(--white);border-bottom:1px solid var(--border)}
.topbar-brand{font-size:18px;font-weight:800;color:var(--accent)}
.topbar-right{display:flex;align-items:center;gap:12px}
.btn-reset{padding:6px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;color:var(--sub);display:flex;align-items:center;gap:5px;transition:all .15s}
.btn-reset:hover{border-color:var(--red);color:var(--red);background:#fef2f2}
.btn-reset svg{width:14px;height:14px}
.main{display:flex;flex:1;overflow:hidden}
.left-panel{width:440px;min-width:440px;border-right:1px solid var(--border);display:flex;flex-direction:column;background:var(--white)}
.setup-view{flex:1;overflow-y:auto;padding:20px}
.panel-section{margin-bottom:20px}
.panel-section-title{font-size:13px;font-weight:700;color:var(--text);margin-bottom:8px;display:flex;align-items:center;gap:6px}
.panel-section-title .num{width:20px;height:20px;border-radius:50%;background:var(--accent);color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center}
.panel-label{font-size:11px;font-weight:600;color:var(--sub);margin-bottom:4px}
.panel-input{width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:8px;font-size:14px;font-family:inherit;outline:none;transition:border-color .2s;resize:vertical}
.panel-input:focus{border-color:var(--accent)}
textarea.panel-input{min-height:80px}
/* Meeting type chips */
.type-row{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px}
.type-chip{padding:7px 14px;border:1px solid var(--border);border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;background:var(--white);color:var(--sub);transition:all .15s}
.type-chip:hover{border-color:var(--accent);color:var(--accent)}
.type-chip.active{background:var(--accent);color:#fff;border-color:var(--accent)}
/* Voice card */
.voice-card{border:1px dashed var(--accent);border-radius:var(--radius);padding:14px 16px;margin-bottom:20px;display:flex;align-items:center;gap:14px;cursor:pointer;transition:all .2s;background:var(--accent-light)}
.voice-card:hover{border-style:solid;box-shadow:0 2px 12px rgba(37,99,235,.12)}
.voice-card.recording{border-color:var(--red);background:#fef2f2;animation:pulse-border 1.5s infinite}
.voice-card.processing{border-color:var(--accent);opacity:.7;cursor:wait}
@keyframes pulse-border{0%,100%{border-color:var(--red)}50%{border-color:rgba(239,68,68,.3)}}
.voice-icon{width:40px;height:40px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .2s}
.voice-card.recording .voice-icon{background:var(--red);animation:pulse-mic 1s infinite}
@keyframes pulse-mic{0%,100%{transform:scale(1)}50%{transform:scale(1.1)}}
.voice-icon svg{width:20px;height:20px}
.voice-text{flex:1}
.voice-title{font-size:13px;font-weight:600;color:var(--text)}
.voice-sub{font-size:11px;color:var(--sub);margin-top:1px}
.voice-timer{font-size:18px;font-weight:700;color:var(--red);font-variant-numeric:tabular-nums;display:none}
.voice-card.recording .voice-timer{display:block}
/* File upload card */
.file-card{border:1px dashed var(--border);border-radius:var(--radius);padding:14px 16px;margin-bottom:20px;display:flex;align-items:center;gap:14px;cursor:pointer;transition:all .2s;background:var(--white)}
.file-card:hover{border-color:var(--accent);border-style:solid;background:var(--accent-light)}
.file-card.processing{border-color:var(--accent);border-style:solid;opacity:.7;cursor:wait}
.file-icon{width:40px;height:40px;border-radius:50%;background:var(--bg);color:var(--sub);display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .2s;border:1px solid var(--border)}
.file-card:hover .file-icon{background:var(--accent);color:#fff;border-color:var(--accent)}
.file-icon svg{width:20px;height:20px}
.file-card .voice-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px}
.file-card .voice-sub{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px}
.btn-generate{width:100%;padding:14px;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s;display:flex;align-items:center;justify-content:center;gap:8px;margin-top:8px}
.btn-generate:hover{background:var(--accent-hover)}
.btn-generate:disabled{opacity:.5;cursor:not-allowed}
.chat-view{flex:1;display:none;flex-direction:column}
.chat-messages{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:8px}
.chat-msg{padding:10px 14px;border-radius:12px;font-size:14px;max-width:85%;word-wrap:break-word;line-height:1.5}
.chat-msg.user{background:var(--accent);color:#fff;align-self:flex-end;border-bottom-right-radius:4px}
.chat-msg.ai{background:var(--bg);border:1px solid var(--border);align-self:flex-start;border-bottom-left-radius:4px}
.chat-msg.system{background:var(--green-bg);color:var(--green);align-self:center;font-size:12px;font-weight:600;border-radius:20px;padding:6px 16px}
.chat-msg.error{background:#fef2f2;color:var(--red);align-self:center;font-size:12px;font-weight:600;border-radius:20px;padding:6px 16px}
.typing{align-self:flex-start;padding:12px 18px;background:var(--bg);border:1px solid var(--border);border-radius:12px;border-bottom-left-radius:4px;display:none}
.typing-dots{display:flex;gap:4px}
.typing-dots span{width:6px;height:6px;background:var(--sub);border-radius:50%;animation:blink 1.4s infinite both}
.typing-dots span:nth-child(2){animation-delay:.2s}
.typing-dots span:nth-child(3){animation-delay:.4s}
@keyframes blink{0%,80%,100%{opacity:.3}40%{opacity:1}}
.chat-bottom{padding:12px 16px;border-top:1px solid var(--border);background:var(--white)}
.chat-row{display:flex;gap:8px}
.chat-input{flex:1;padding:12px 14px;border:1px solid var(--border);border-radius:10px;font-size:14px;font-family:inherit;outline:none;resize:none}
.chat-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.08)}
.chat-send{width:44px;height:44px;background:var(--accent);color:#fff;border:none;border-radius:10px;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0}
.chat-send:hover{background:var(--accent-hover)}
.chat-send:disabled{opacity:.4;cursor:not-allowed}
.chat-mic{width:44px;height:44px;background:var(--bg);color:var(--sub);border:1px solid var(--border);border-radius:10px;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0}
.chat-mic:hover{border-color:var(--accent);color:var(--accent)}
.chat-mic.recording{background:var(--red);color:#fff;border-color:var(--red);animation:pulse-mic 1s infinite}
.chat-mic svg{width:20px;height:20px}
.publish-group{display:none;align-items:center;gap:8px}
.publish-group .pub-url{display:flex;align-items:center;gap:0;background:var(--bg);border:1px solid var(--border);border-radius:8px;overflow:hidden}
.publish-group .pub-prefix{font-size:12px;color:var(--sub);padding:6px 0 6px 10px;white-space:nowrap}
.publish-group .pub-slug{border:none;outline:none;font-size:12px;font-family:inherit;padding:6px 8px 6px 0;background:transparent;width:100px}
.publish-group .pub-status{font-size:10px;white-space:nowrap;padding-right:8px}
.btn-publish{padding:7px 16px;background:var(--green);color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap;transition:all .2s}
.btn-publish:hover{background:#059669}
.btn-publish:disabled{opacity:.5;cursor:not-allowed}
.right-panel{flex:1;display:flex;flex-direction:column;background:var(--bg)}
.preview-bar{display:flex;align-items:center;justify-content:space-between;padding:12px 20px;background:var(--white);border-bottom:1px solid var(--border);display:none}
.preview-tabs{display:flex;gap:4px}
.preview-tab{padding:6px 14px;border-radius:6px;font-size:12px;font-weight:600;border:none;cursor:pointer;font-family:inherit;background:transparent;color:var(--sub);transition:all .2s}
.preview-tab.active{background:var(--accent-light);color:var(--accent)}
.preview-frame{flex:1;border:none;background:#fff;display:none}
.preview-empty{flex:1;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px;color:var(--sub);padding:40px}
.preview-empty svg{width:64px;height:64px;opacity:.3}
.preview-empty p{font-size:15px;text-align:center}
.toast{position:fixed;bottom:24px;right:24px;padding:12px 20px;border-radius:10px;font-size:14px;font-weight:500;color:#fff;z-index:1000;transform:translateY(100px);opacity:0;transition:all .3s ease}
.toast.show{transform:translateY(0);opacity:1}
.toast.success{background:var(--green)}
.toast.error{background:var(--red)}
.toast.info{background:var(--accent)}
.spinner-sm{width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:none}
@keyframes spin{to{transform:rotate(360deg)}}
@media(max-width:900px){
.main{flex-direction:column}
.left-panel{width:100%;min-width:0;max-height:50vh;border-right:none;border-bottom:1px solid var(--border)}
.right-panel{min-height:50vh}
}
</style>
</head>
<body>
<!-- AUTH -->
<div class="auth-screen" id="authScreen">
<div class="auth-card">
<div class="auth-logo">Edemium</div>
<div class="auth-sub">Summary Builder</div>
<input type="password" class="auth-input" id="authInput" placeholder="Пароль" autofocus>
<button class="auth-btn" id="authBtn" onclick="doAuth()">Войти</button>
<div class="auth-error" id="authError">Неверный пароль</div>
</div>
</div>
<!-- APP -->
<div class="app-screen" id="appScreen">
<div class="topbar">
<div class="topbar-brand">Summary Builder</div>
<div class="topbar-right">
<button class="btn-reset" id="btnReset" onclick="doReset()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>Сбросить</button>
<span style="font-size:12px" id="statusText"></span>
<div class="publish-group" id="publishGroup">
<div class="pub-url">
<span class="pub-prefix">summary.edemium.ru/</span>
<input type="text" class="pub-slug" id="pubSlug" placeholder="auto" oninput="checkSlug()">
<span class="pub-status" id="slugStatus"></span>
</div>
<button class="btn-publish" id="publishBtn" onclick="doDeploy()">Опубликовать</button>
<button class="btn-publish" style="background:var(--accent)" id="docxBtn" onclick="doDownloadDocx()">Скачать .docx</button>
</div>
</div>
</div>
<div class="main">
<!-- LEFT -->
<div class="left-panel">
<!-- Setup (before generation) -->
<div class="setup-view" id="setupView">
<!-- Voice input card -->
<div class="voice-card" id="voiceCard" onclick="toggleVoice()">
<div class="voice-icon" id="voiceIcon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>
</div>
<div class="voice-text">
<div class="voice-title" id="voiceTitle">Расскажите голосом</div>
<div class="voice-sub" id="voiceSub">Опишите встречу — ИИ заполнит все поля</div>
</div>
<div class="voice-timer" id="voiceTimer">0:00</div>
</div>
<!-- File upload card -->
<div class="file-card" id="fileCard" onclick="document.getElementById('fileInput').click()">
<div class="file-icon" id="fileIcon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</div>
<div class="voice-text">
<div class="voice-title" id="fileTitle">Загрузите запись</div>
<div class="voice-sub" id="fileSub">Аудио, видео или текст встречи</div>
</div>
</div>
<input type="file" id="fileInput" style="display:none" accept=".txt,.mp3,.m4a,.wav,.ogg,.webm,.mp4,.mov,.aac,.flac" onchange="handleFileUpload(this)">
<!-- Block 1: Meeting info -->
<div class="panel-section">
<div class="panel-section-title"><span class="num">1</span> Встреча</div>
<div class="panel-label">Тема встречи</div>
<input type="text" class="panel-input" id="meetingTitle" placeholder="Презентация решений для автоматизации">
<div style="height:8px"></div>
<div class="panel-label">Дата</div>
<input type="date" class="panel-input" id="meetingDate" style="padding:9px 12px">
<div style="height:8px"></div>
<div class="panel-label">Участники</div>
<input type="text" class="panel-input" id="participants" placeholder="Владислав, Иван, Алексей">
<div style="height:10px"></div>
<div class="panel-label">Тип встречи</div>
<div class="type-row" id="typeRow">
<div class="type-chip active" data-type="общая">Общая</div>
<div class="type-chip" data-type="продажа">Продажа</div>
<div class="type-chip" data-type="брифинг">Брифинг</div>
<div class="type-chip" data-type="интервью">Интервью</div>
<div class="type-chip" data-type="стратегия">Стратегия</div>
</div>
</div>
<!-- Block 2: Transcript -->
<div class="panel-section">
<div class="panel-section-title"><span class="num">2</span> Транскрипт</div>
<div class="panel-label">Текст встречи (заполняется автоматически или вручную)</div>
<textarea class="panel-input" id="transcript" style="min-height:140px" placeholder="Вставьте транскрипт встречи или запишите/загрузите файл..."></textarea>
<div style="height:10px"></div>
<div class="panel-label" style="display:flex;align-items:center;gap:4px">Контекст / Цели <span style="font-weight:400;color:var(--sub);font-size:10px">— опционально</span></div>
<textarea class="panel-input" id="context" style="min-height:60px;font-size:13px" placeholder="Дополнительная информация: цель встречи, контекст, что важно учесть..."></textarea>
</div>
<button class="btn-generate" id="generateBtn" onclick="doGenerate()">
<span>Сгенерировать саммари</span>
<div class="spinner-sm" id="genSpinner"></div>
</button>
</div>
<!-- Chat (after generation) -->
<div class="chat-view" id="chatView">
<div class="chat-messages" id="chatMessages"></div>
<div class="chat-bottom">
<div class="chat-row">
<button class="chat-mic" id="chatMic" onclick="toggleChatMic()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>
</button>
<input type="text" class="chat-input" id="chatInput" placeholder="Напиши или скажи голосом..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();doRefine()}">
<button class="chat-send" id="refineBtn" onclick="doRefine()">&#10148;</button>
</div>
</div>
</div>
</div>
<!-- RIGHT -->
<div class="right-panel">
<div class="preview-bar" id="previewBar">
<span>Превью саммари</span>
<div class="preview-tabs">
<button class="preview-tab active" onclick="setView('desktop',this)">Десктоп</button>
<button class="preview-tab" onclick="setView('mobile',this)">Мобилка</button>
</div>
</div>
<div class="preview-empty" id="previewEmpty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z"/><path d="M9 13h6M9 17h3M9 9h6"/></svg>
<p>Заполните данные слева и нажмите<br><b>Сгенерировать саммари</b></p>
<p style="font-size:12px;color:var(--sub);margin-top:8px;max-width:320px;text-align:center;line-height:1.5">ИИ проанализирует встречу и создаст структурированное саммари с ключевыми решениями, действиями и цитатами</p>
</div>
<iframe class="preview-frame" id="previewFrame"></iframe>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
let currentHtml = '';
let isProcessing = false;
let meetingType = 'общая';
let stateRestored = false;
let saveTimer = null;
let voiceState = 'idle';
let mediaRecorder = null;
let audioChunks = [];
let voiceTimerInterval = null;
let voiceStartTime = 0;
let checkTimer = null;
let chatMicState = 'idle';
let chatRecorder = null;
let chatAudioChunks = [];
// Set today's date as default
document.getElementById('meetingDate').valueAsDate = new Date();
// -- Auth --
document.getElementById('authInput').addEventListener('keydown', e => {
if (e.key === 'Enter') doAuth();
});
async function doAuth(pw, silent) {
if (!pw) pw = document.getElementById('authInput').value;
const btn = document.getElementById('authBtn');
if (!silent) { btn.disabled = true; btn.textContent = '...'; }
try {
const res = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw })
});
if (res.ok) {
localStorage.setItem('sb_pw', pw);
document.getElementById('authScreen').style.display = 'none';
document.getElementById('appScreen').style.display = 'flex';
restoreState();
} else {
localStorage.removeItem('sb_pw');
document.getElementById('appScreen').style.display = 'none';
document.getElementById('authScreen').style.display = 'flex';
if (!silent) {
document.getElementById('authInput').classList.add('error');
document.getElementById('authError').style.display = 'block';
setTimeout(() => document.getElementById('authInput').classList.remove('error'), 1500);
}
}
} catch (err) {
if (!silent) toast('Ошибка соединения', 'error');
}
if (!silent) { btn.disabled = false; btn.textContent = 'Войти'; }
}
async function authFetch(url, opts) {
let res = await fetch(url, opts);
if (res.status === 401) {
const pw = localStorage.getItem('sb_pw');
if (pw) {
await fetch('/api/auth', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
res = await fetch(url, opts);
}
}
return res;
}
const saved = localStorage.getItem('sb_pw');
if (saved) {
document.getElementById('appScreen').style.display = 'flex';
restoreState();
doAuth(saved, true);
} else {
document.getElementById('authScreen').style.display = 'flex';
}
// -- Meeting type chips --
document.getElementById('typeRow').addEventListener('click', e => {
const chip = e.target.closest('.type-chip');
if (!chip) return;
document.querySelectorAll('.type-chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
meetingType = chip.dataset.type;
saveState();
});
// -- Voice input --
async function toggleVoice() {
if (voiceState === 'processing') return;
if (voiceState === 'recording') { stopVoice(); return; }
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
audioChunks = [];
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
mediaRecorder.onstop = () => {
stream.getTracks().forEach(t => t.stop());
processVoice();
};
mediaRecorder.start();
voiceState = 'recording';
voiceStartTime = Date.now();
const card = document.getElementById('voiceCard');
card.classList.add('recording');
card.classList.remove('processing');
document.getElementById('voiceTitle').textContent = 'Запись... нажмите, чтобы остановить';
document.getElementById('voiceSub').textContent = 'Расскажите о встрече — ИИ заполнит поля';
document.getElementById('voiceIcon').innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>';
updateVoiceTimer();
voiceTimerInterval = setInterval(updateVoiceTimer, 1000);
} catch (e) {
toast('Нет доступа к микрофону', 'error');
}
}
function updateVoiceTimer() {
const elapsed = Math.floor((Date.now() - voiceStartTime) / 1000);
document.getElementById('voiceTimer').textContent = Math.floor(elapsed / 60) + ':' + String(elapsed % 60).padStart(2, '0');
}
function stopVoice() {
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
clearInterval(voiceTimerInterval);
}
async function processVoice() {
voiceState = 'processing';
const card = document.getElementById('voiceCard');
card.classList.remove('recording');
card.classList.add('processing');
document.getElementById('voiceTitle').textContent = 'Обрабатываю запись...';
document.getElementById('voiceSub').textContent = 'Расшифровка и анализ через ИИ';
document.getElementById('voiceIcon').innerHTML = '<div class="spinner-sm" style="display:block;border-color:rgba(255,255,255,.3);border-top-color:#fff"></div>';
try {
const blob = new Blob(audioChunks, { type: 'audio/webm' });
const formData = new FormData();
formData.append('audio', blob, 'voice.webm');
formData.append('currentFields', getCurrentFields());
const res = await authFetch('/api/voice-fill', { method: 'POST', body: formData });
const data = await res.json();
if (data.ok && data.fields) {
fillFieldsFromVoice(data.fields);
toast('Поля заполнены из голоса', 'success');
document.getElementById('voiceTitle').textContent = 'Готово! Проверьте поля ниже';
document.getElementById('voiceSub').textContent = data.transcript ? '\u00AB' + data.transcript.substring(0, 80) + '...\u00BB' : 'Можно поправить вручную';
} else {
toast('Не удалось распознать', 'error');
document.getElementById('voiceTitle').textContent = 'Не удалось распознать';
document.getElementById('voiceSub').textContent = 'Попробуйте ещё раз';
}
if (data.transcript) {
const ta = document.getElementById('transcript');
ta.value = (ta.value ? ta.value + '\n\n' : '') + data.transcript;
saveState();
}
} catch (e) {
toast('Ошибка: ' + e.message, 'error');
}
voiceState = 'idle';
card.classList.remove('processing');
document.getElementById('voiceIcon').innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
setTimeout(() => {
document.getElementById('voiceTitle').textContent = 'Расскажите голосом';
document.getElementById('voiceSub').textContent = 'Опишите встречу — ИИ заполнит все поля';
}, 5000);
}
function getCurrentFields() {
return JSON.stringify({
meetingTitle: document.getElementById('meetingTitle').value.trim(),
participants: document.getElementById('participants').value.trim(),
meetingType: meetingType,
context: document.getElementById('context').value.trim()
});
}
function fillFieldsFromVoice(f) {
if (f.meetingTitle && f.meetingTitle.trim()) document.getElementById('meetingTitle').value = f.meetingTitle;
if (f.participants && f.participants.trim()) document.getElementById('participants').value = f.participants;
if (f.context && f.context.trim()) {
const ctx = document.getElementById('context');
ctx.value = (ctx.value ? ctx.value + '\n\n' : '') + f.context;
}
if (f.meetingType) {
const chips = document.querySelectorAll('.type-chip');
for (const chip of chips) {
if (chip.dataset.type === f.meetingType) {
document.querySelectorAll('.type-chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
meetingType = f.meetingType;
break;
}
}
}
saveState();
}
// -- File upload --
async function handleFileUpload(input) {
const file = input.files[0];
if (!file) return;
input.value = '';
const card = document.getElementById('fileCard');
if (file.size > 200 * 1024 * 1024) { toast('Файл слишком большой (макс. 200 МБ)', 'error'); return; }
card.classList.add('processing');
const ext = file.name.split('.').pop().toLowerCase();
const isText = ext === 'txt';
const sizeMB = (file.size / 1024 / 1024).toFixed(1);
document.getElementById('fileTitle').textContent = file.name;
document.getElementById('fileSub').textContent = isText ? 'Читаю текст...' : 'Отправляю на транскрипцию...';
document.getElementById('fileIcon').innerHTML = '<div class="spinner-sm" style="display:block;border-color:rgba(107,114,128,.3);border-top-color:var(--accent)"></div>';
const steps = isText
? ['Читаю текст...', 'Анализирую содержание...', 'Извлекаю метаданные...']
: ['Отправляю на транскрипцию...', 'Расшифровываю речь (' + sizeMB + ' МБ)...', 'Анализирую диалог...', 'Определяю участников и темы...', 'Заполняю поля...'];
let stepIdx = 0;
const statusInterval = setInterval(() => {
stepIdx++;
if (stepIdx < steps.length) document.getElementById('fileSub').textContent = steps[stepIdx];
}, isText ? 3000 : 5000);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('currentFields', getCurrentFields());
const res = await authFetch('/api/file-fill', { method: 'POST', body: formData });
clearInterval(statusInterval);
const data = await res.json();
if (data.ok && data.fields) {
fillFieldsFromVoice(data.fields);
toast('Поля заполнены из файла', 'success');
document.getElementById('fileTitle').textContent = 'Готово! Проверьте поля';
document.getElementById('fileSub').textContent = 'Данные извлечены';
} else {
toast(data.error || 'Не удалось обработать файл', 'error');
document.getElementById('fileTitle').textContent = 'Ошибка обработки';
document.getElementById('fileSub').textContent = data.error || 'Попробуйте другой файл';
}
if (data.transcript) {
const ta = document.getElementById('transcript');
ta.value = (ta.value ? ta.value + '\n\n' : '') + data.transcript;
saveState();
}
} catch (e) {
clearInterval(statusInterval);
toast('Ошибка: ' + e.message, 'error');
document.getElementById('fileTitle').textContent = 'Ошибка';
document.getElementById('fileSub').textContent = e.message;
}
card.classList.remove('processing');
document.getElementById('fileIcon').innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
setTimeout(() => {
document.getElementById('fileTitle').textContent = 'Загрузите запись';
document.getElementById('fileSub').textContent = 'Аудио, видео или текст встречи';
}, 5000);
}
// -- Save / Restore state --
function saveState() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try {
const state = {
meetingTitle: document.getElementById('meetingTitle').value,
meetingDate: document.getElementById('meetingDate').value,
participants: document.getElementById('participants').value,
meetingType: meetingType,
transcript: document.getElementById('transcript').value,
context: document.getElementById('context').value,
html: currentHtml
};
localStorage.setItem('sb_state', JSON.stringify(state));
} catch (e) {
try {
const lite = {
meetingTitle: document.getElementById('meetingTitle').value,
meetingDate: document.getElementById('meetingDate').value,
participants: document.getElementById('participants').value,
meetingType: meetingType,
transcript: document.getElementById('transcript').value.substring(0, 5000),
context: document.getElementById('context').value,
html: currentHtml.substring(0, 500000)
};
localStorage.setItem('sb_state', JSON.stringify(lite));
} catch (e2) {}
}
}, 300);
}
function restoreState() {
if (stateRestored) return;
const raw = localStorage.getItem('sb_state');
if (!raw) return;
stateRestored = true;
try {
const s = JSON.parse(raw);
if (s.meetingTitle) document.getElementById('meetingTitle').value = s.meetingTitle;
if (s.meetingDate) document.getElementById('meetingDate').value = s.meetingDate;
if (s.participants) document.getElementById('participants').value = s.participants;
if (s.transcript) document.getElementById('transcript').value = s.transcript;
if (s.context) document.getElementById('context').value = s.context;
if (s.meetingType) {
meetingType = s.meetingType;
document.querySelectorAll('.type-chip').forEach(c => {
c.classList.toggle('active', c.dataset.type === meetingType);
});
}
if (s.html) {
currentHtml = s.html;
showPreview(currentHtml);
switchToChat();
addMsg('Саммари восстановлено. Напиши что поменять или нажми «Опубликовать»', 'ai');
}
} catch (e) {}
}
document.querySelectorAll('#meetingTitle,#meetingDate,#participants,#transcript,#context').forEach(el => {
el.addEventListener('input', saveState);
});
// -- Switch to chat --
function switchToChat() {
document.getElementById('setupView').style.display = 'none';
document.getElementById('chatView').style.display = 'flex';
document.getElementById('publishGroup').style.display = 'flex';
document.getElementById('previewBar').style.display = 'flex';
document.getElementById('chatInput').focus();
}
// -- Reset --
function doReset() {
if (!confirm('Сбросить всё? Поля и саммари будут очищены.')) return;
localStorage.removeItem('sb_state');
currentHtml = '';
document.getElementById('meetingTitle').value = '';
document.getElementById('meetingDate').valueAsDate = new Date();
document.getElementById('participants').value = '';
document.getElementById('transcript').value = '';
document.getElementById('context').value = '';
meetingType = 'общая';
document.querySelectorAll('.type-chip').forEach(c => c.classList.toggle('active', c.dataset.type === 'общая'));
document.getElementById('setupView').style.display = '';
document.getElementById('chatView').style.display = 'none';
document.getElementById('publishGroup').style.display = 'none';
document.getElementById('previewBar').style.display = 'none';
document.getElementById('previewFrame').style.display = 'none';
document.getElementById('previewEmpty').style.display = 'flex';
document.getElementById('chatMessages').innerHTML = '';
document.getElementById('statusText').textContent = '';
document.getElementById('pubSlug').value = '';
document.getElementById('slugStatus').textContent = '';
toast('Всё сброшено', 'info');
}
// -- Generate (SSE streaming) --
async function doGenerate() {
const transcript = document.getElementById('transcript').value.trim();
const context = document.getElementById('context').value.trim();
if (!transcript && !context) {
toast('Заполните транскрипт или контекст встречи', 'error');
return;
}
const btn = document.getElementById('generateBtn');
const spinner = document.getElementById('genSpinner');
btn.disabled = true;
spinner.style.display = 'block';
btn.querySelector('span').textContent = 'Генерирую...';
const frame = document.getElementById('previewFrame');
frame.style.display = 'block';
document.getElementById('previewEmpty').style.display = 'none';
document.getElementById('previewBar').style.display = 'flex';
const doc = frame.contentDocument || frame.contentWindow.document;
doc.open();
try {
const res = await authFetch('/api/generate-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transcript,
meetingTitle: document.getElementById('meetingTitle').value.trim(),
meetingDate: document.getElementById('meetingDate').value,
participants: document.getElementById('participants').value.trim(),
meetingType,
context
})
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finished = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const p = JSON.parse(line.slice(6));
if (p.error) { toast('Ошибка: ' + p.error, 'error'); break; }
if (p.chunk) doc.write(p.chunk);
if (p.done) {
doc.close();
currentHtml = p.html;
finished = true;
showPreview(currentHtml);
switchToChat();
addMsg('Саммари готово. Напиши что поменять или нажми «Опубликовать»', 'ai');
toast('Саммари готово', 'success');
saveState();
}
} catch (e) {}
}
}
if (!finished) { doc.close(); toast('Генерация прервалась', 'error'); }
} catch (err) {
try { doc.close(); } catch(e) {}
toast('Ошибка: ' + err.message, 'error');
}
btn.disabled = false;
spinner.style.display = 'none';
btn.querySelector('span').textContent = 'Сгенерировать саммари';
}
// -- Refine --
async function doRefine() {
const input = document.getElementById('chatInput');
const msg = input.value.trim();
if (!msg || !currentHtml || isProcessing) return;
isProcessing = true;
addMsg(msg, 'user');
input.value = '';
const btn = document.getElementById('refineBtn');
btn.disabled = true;
const typing = showTyping();
try {
const res = await authFetch('/api/refine', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: currentHtml, message: msg })
});
hideTyping(typing);
let data;
try { data = await res.json(); }
catch (parseErr) {
addMsg('Ответ слишком большой. Попробуй сформулировать короче.', 'error');
btn.disabled = false;
isProcessing = false;
return;
}
if (data.ok) {
currentHtml = data.html;
showPreview(currentHtml);
addMsg('Готово, превью обновлено', 'ai');
saveState();
} else {
addMsg('Ошибка: ' + data.error, 'error');
}
} catch (err) {
hideTyping(typing);
addMsg('Ошибка соединения. Попробуй ещё раз.', 'error');
}
btn.disabled = false;
isProcessing = false;
input.focus();
}
// -- Chat helpers --
function addMsg(text, role) {
const el = document.createElement('div');
el.className = 'chat-msg ' + role;
el.textContent = text;
const box = document.getElementById('chatMessages');
box.appendChild(el);
box.scrollTop = box.scrollHeight;
}
function showTyping() {
const el = document.createElement('div');
el.className = 'typing';
el.style.display = 'block';
el.innerHTML = '<div class="typing-dots"><span></span><span></span><span></span></div>';
const box = document.getElementById('chatMessages');
box.appendChild(el);
box.scrollTop = box.scrollHeight;
return el;
}
function hideTyping(el) {
if (el && el.parentNode) el.parentNode.removeChild(el);
}
// -- Preview --
function showPreview(html) {
const frame = document.getElementById('previewFrame');
frame.style.display = 'block';
document.getElementById('previewEmpty').style.display = 'none';
document.getElementById('previewBar').style.display = 'flex';
const doc = frame.contentDocument || frame.contentWindow.document;
doc.open();
doc.write(html);
doc.close();
}
function setView(mode, btn) {
const frame = document.getElementById('previewFrame');
document.querySelectorAll('.preview-tab').forEach(t => t.classList.remove('active'));
btn.classList.add('active');
if (mode === 'mobile') {
frame.style.maxWidth = '390px';
frame.style.margin = '0 auto';
frame.style.borderLeft = '1px solid var(--border)';
frame.style.borderRight = '1px solid var(--border)';
} else {
frame.style.maxWidth = '';
frame.style.margin = '';
frame.style.borderLeft = '';
frame.style.borderRight = '';
}
}
// -- Slug check --
function checkSlug() {
const val = document.getElementById('pubSlug').value.trim();
const status = document.getElementById('slugStatus');
if (!val) { status.textContent = ''; return; }
clearTimeout(checkTimer);
checkTimer = setTimeout(async () => {
try {
const res = await authFetch('/api/check-slug?name=' + encodeURIComponent(val));
if (!res.ok) return;
const data = await res.json();
if (data.ok && data.free === true) {
status.innerHTML = '<span style="color:var(--green)">&#10003; свободен</span>';
} else if (data.ok && data.free === false) {
status.innerHTML = '<span style="color:var(--red)">занят</span>';
}
} catch (e) {}
}, 400);
}
// -- Publish --
async function doDeploy() {
if (!currentHtml) { toast('Сначала сгенерируй саммари', 'error'); return; }
const slug = document.getElementById('pubSlug').value.trim();
const btn = document.getElementById('publishBtn');
btn.disabled = true;
btn.textContent = 'Публикую...';
try {
const res = await authFetch('/api/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: currentHtml, slug: slug || '' })
});
const data = await res.json();
if (data.ok) {
addMsg('Опубликовано: ' + data.url, 'ai');
document.getElementById('statusText').innerHTML = '<a href="'+data.url+'" target="_blank" style="color:var(--green);font-size:12px;font-weight:600">'+data.url+'</a>';
if (data.name) document.getElementById('pubSlug').value = data.name;
toast('Саммари опубликовано!', 'success');
} else {
addMsg('Ошибка: ' + data.error, 'error');
}
} catch (err) {
addMsg('Ошибка: ' + err.message, 'error');
}
btn.disabled = false;
btn.textContent = 'Опубликовать';
}
// -- Download .docx --
async function doDownloadDocx() {
if (!currentHtml) { toast('Сначала сгенерируй саммари', 'error'); return; }
const btn = document.getElementById('docxBtn');
btn.disabled = true;
btn.textContent = 'Создаю...';
try {
const res = await authFetch('/api/download-docx', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: currentHtml })
});
if (!res.ok) { const d = await res.json(); toast(d.error || 'Ошибка', 'error'); return; }
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'summary.docx';
a.click();
URL.revokeObjectURL(url);
toast('Файл скачан', 'success');
} catch (e) {
toast('Ошибка: ' + e.message, 'error');
}
btn.disabled = false;
btn.textContent = 'Скачать .docx';
}
// -- Chat mic --
async function toggleChatMic() {
const btn = document.getElementById('chatMic');
if (chatMicState === 'processing') return;
if (chatMicState === 'recording') {
chatMicState = 'processing';
btn.classList.remove('recording');
btn.innerHTML = '<div class="spinner-sm" style="display:block;border-color:rgba(107,114,128,.3);border-top-color:var(--accent);width:18px;height:18px"></div>';
if (chatRecorder && chatRecorder.state === 'recording') chatRecorder.stop();
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
chatRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
chatAudioChunks = [];
chatRecorder.ondataavailable = e => { if (e.data.size > 0) chatAudioChunks.push(e.data); };
chatRecorder.onstop = async () => {
stream.getTracks().forEach(t => t.stop());
try {
const blob = new Blob(chatAudioChunks, { type: 'audio/webm' });
const fd = new FormData();
fd.append('audio', blob, 'chat.webm');
const res = await authFetch('/api/transcribe', { method: 'POST', body: fd });
const data = await res.json();
if (data.ok && data.text) {
document.getElementById('chatInput').value = data.text;
doRefine();
} else {
toast('Не удалось распознать', 'error');
}
} catch (e) {
toast('Ошибка: ' + e.message, 'error');
}
chatMicState = 'idle';
btn.classList.remove('recording');
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
};
chatRecorder.start();
chatMicState = 'recording';
btn.classList.add('recording');
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>';
} catch (e) {
toast('Нет доступа к микрофону', 'error');
}
}
// -- Toast --
function toast(msg, type) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast ' + type + ' show';
setTimeout(() => el.classList.remove('show'), 3000);
}
</script>
</body>
</html>