Initial commit: summary.edemium.ru - meeting summary generator

This commit is contained in:
root
2026-02-12 12:17:36 +00:00
commit 085cc9a837
6 changed files with 1644 additions and 0 deletions

964
public/index.html Normal file
View File

@@ -0,0 +1,964 @@
<!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>