Initial commit: summary.edemium.ru - meeting summary generator
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
sites/
|
||||
7
Dockerfile
Normal file
7
Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY . .
|
||||
EXPOSE 3004
|
||||
CMD ["node", "server.js"]
|
||||
15
package.json
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "summary-builder",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"openai": "^4.52.0"
|
||||
}
|
||||
}
|
||||
964
public/index.html
Normal file
964
public/index.html
Normal 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()">➤</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)">✓ свободен</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>
|
||||
481
server.js
Normal file
481
server.js
Normal file
@@ -0,0 +1,481 @@
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const OpenAI = require('openai');
|
||||
const multer = require('multer');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3004;
|
||||
const PASSWORD = process.env.PASSWORD || '1928812Edemium';
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
const BOT_TOKEN = process.env.BOT_TOKEN || '';
|
||||
const CHAT_ID = process.env.CHAT_ID || '';
|
||||
const SITES_DIR = process.env.SITES_DIR || '/sites/summaries';
|
||||
|
||||
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
|
||||
const DEEPGRAM_KEY = process.env.DEEPGRAM_KEY || '';
|
||||
const uploadSmall = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
|
||||
const uploadLarge = multer({ storage: multer.memoryStorage(), limits: { fileSize: 200 * 1024 * 1024 } });
|
||||
|
||||
fs.mkdirSync(SITES_DIR, { recursive: true });
|
||||
|
||||
const TEMPLATE = fs.readFileSync(path.join(__dirname, 'template-meeting.html'), 'utf-8');
|
||||
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
app.use(session({
|
||||
secret: 'summary-builder-edemium-2026',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: { maxAge: 24 * 60 * 60 * 1000 }
|
||||
}));
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.session && req.session.authenticated) return next();
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
app.post('/api/auth', (req, res) => {
|
||||
if (req.body.password === PASSWORD) {
|
||||
req.session.authenticated = true;
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(403).json({ error: 'Wrong password' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/check-auth', (req, res) => {
|
||||
res.json({ authenticated: !!(req.session && req.session.authenticated) });
|
||||
});
|
||||
|
||||
// Build system prompt for meeting summary generation
|
||||
function buildSystemPrompt(meetingType) {
|
||||
return `Ты — генератор HTML-саммари встреч в стиле WORD-ДОКУМЕНТА.
|
||||
|
||||
КОНТЕКСТ:
|
||||
Тебе дают транскрипт или заметки со встречи. Ты должен проанализировать содержание и создать структурированное саммари.
|
||||
Тип встречи: ${meetingType || 'общая встреча'}
|
||||
|
||||
ПРАВИЛА:
|
||||
1. Ответ — ТОЛЬКО HTML-код, без markdown, без \`\`\`, без пояснений
|
||||
2. Копируй CSS и паттерны из шаблона ниже ТОЧНО (стили, классы, структуру)
|
||||
3. Это СТАТИЧНЫЙ документ — никаких form, input, textarea, кнопок
|
||||
4. Цветовая схема: синий --accent: #2563eb, --accent-dark: #1e40af
|
||||
5. Обязательно @media print стили
|
||||
|
||||
КРИТИЧЕСКИ ВАЖНЫЙ СТИЛЬ — ДОКУМЕНТНЫЙ, КАК В WORD:
|
||||
- НЕ используй border-radius НИГДЕ
|
||||
- НЕ используй карточки (card), тени на элементах, цветные подложки
|
||||
- Чеклисты — текстовый символ ✓ через ::before
|
||||
- Таблицы — тёмный заголовок (--accent-dark), центрированный текст (кроме 1-го столбца — left)
|
||||
- Всё должно выглядеть как документ из Microsoft Word / Google Docs
|
||||
|
||||
ФОРМАТ:
|
||||
- body background: #f0f1f3
|
||||
- Контент в блоках .doc-page — белые A4-страницы (width:210mm, min-height:297mm, padding:50px 60px)
|
||||
- Обложка — .doc-page.cover-page
|
||||
- Контент — 1-2 блока .doc-page
|
||||
|
||||
УНИВЕРСАЛЬНАЯ СТРУКТУРА:
|
||||
НЕ используй фиксированный набор секций. Анализируй содержание и включай ТОЛЬКО релевантные разделы:
|
||||
|
||||
ОБЯЗАТЕЛЬНЫЕ разделы (всегда):
|
||||
1. Обложка (.cover-page): заголовок "САММАРИ ВСТРЕЧИ", тема, дата, участники
|
||||
2. Контекст встречи — о чём и зачем (2-3 предложения)
|
||||
3. Ключевые темы — таблица (тема | суть обсуждения)
|
||||
4. Действия — таблица (задача | исполнитель | срок). Извлеки конкретные задачи с именами и сроками.
|
||||
5. Следующие шаги — нумерованный список
|
||||
|
||||
ОПЦИОНАЛЬНЫЕ разделы (включай если есть в транскрипте):
|
||||
- Решения — таблица (решение | ответственный | комментарий)
|
||||
- Возражения/Вопросы — таблица (вопрос | кто | ответ). Особенно для встреч типа "продажа"
|
||||
- Обсуждённые цены/бюджеты — таблица. Для продаж и брифингов
|
||||
- Ключевые цитаты — блоки .quote-item с .quote-text и .quote-author
|
||||
- Рекомендации — checklist с ✓
|
||||
|
||||
АДАПТАЦИЯ ПО ТИПУ ВСТРЕЧИ:
|
||||
- "продажа" → акцент на возражения, цены, следующие шаги по закрытию
|
||||
- "брифинг" → акцент на требования, технические детали, бюджет
|
||||
- "интервью" → акцент на ключевые ответы (таблица вопрос|ответ), впечатления
|
||||
- "стратегия" → акцент на стратегические решения, цели, метрики
|
||||
- "общая" → сбалансированная структура
|
||||
|
||||
ВАЖНО:
|
||||
- Извлекай КОНКРЕТИКУ: имена, даты, цифры, суммы
|
||||
- Не выдумывай информацию — если чего-то нет в транскрипте, не включай
|
||||
- Если транскрипт короткий — делай компактное саммари (1 страница контента)
|
||||
- Если транскрипт длинный — подробное (2-3 страницы)
|
||||
- Всё на русском языке
|
||||
|
||||
ШАБЛОН (БЕРИ СТИЛИ И ПАТТЕРНЫ ТОЧНО):
|
||||
|
||||
${TEMPLATE}
|
||||
|
||||
Генерируй саммари встречи.`;
|
||||
}
|
||||
|
||||
// Generate summary (SSE streaming)
|
||||
app.post('/api/generate-stream', requireAuth, async (req, res) => {
|
||||
const { transcript, meetingTitle, meetingDate, participants, meetingType, context } = req.body;
|
||||
|
||||
if (!transcript && !context) {
|
||||
return res.status(400).json({ error: 'Нужен транскрипт или контекст встречи' });
|
||||
}
|
||||
|
||||
let userMsg = '';
|
||||
if (meetingTitle) userMsg += `Тема встречи: ${meetingTitle}\n`;
|
||||
if (meetingDate) userMsg += `Дата: ${meetingDate}\n`;
|
||||
if (participants) userMsg += `Участники: ${participants}\n`;
|
||||
if (context) userMsg += `Контекст/цели: ${context}\n`;
|
||||
userMsg += `\nТРАНСКРИПТ ВСТРЕЧИ:\n${transcript || '(транскрипт не предоставлен, используй контекст выше)'}`;
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
try {
|
||||
const stream = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{ role: 'system', content: buildSystemPrompt(meetingType) },
|
||||
{ role: 'user', content: userMsg }
|
||||
],
|
||||
max_tokens: 16000,
|
||||
temperature: 0.5,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let fullHtml = '';
|
||||
let skipPrefix = true;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const content = chunk.choices[0]?.delta?.content;
|
||||
if (!content) continue;
|
||||
fullHtml += content;
|
||||
|
||||
if (skipPrefix) {
|
||||
const trimmed = fullHtml.replace(/^```html?\n?/i, '');
|
||||
if (trimmed !== fullHtml) {
|
||||
fullHtml = trimmed;
|
||||
skipPrefix = false;
|
||||
if (trimmed.length > 0) {
|
||||
res.write(`data: ${JSON.stringify({ chunk: trimmed })}\n\n`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (fullHtml.length > 10) skipPrefix = false;
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify({ chunk: content })}\n\n`);
|
||||
}
|
||||
|
||||
fullHtml = fullHtml.replace(/\n?```$/i, '').trim();
|
||||
res.write(`data: ${JSON.stringify({ done: true, html: fullHtml })}\n\n`);
|
||||
res.end();
|
||||
|
||||
// Telegram notification
|
||||
notifyTelegram(meetingTitle || 'Без темы', meetingType || 'общая');
|
||||
} catch (err) {
|
||||
console.error('Stream error:', err);
|
||||
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Refine summary via chat
|
||||
app.post('/api/refine', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { html, message } = req.body;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `Ты — редактор HTML саммари встреч в стиле Word-документа.
|
||||
Тебе дают текущий HTML-код саммари и инструкцию по правке.
|
||||
Верни ПОЛНЫЙ обновлённый HTML-код (не фрагмент, а весь файл).
|
||||
ТОЛЬКО HTML-код, без markdown, без \`\`\`, без пояснений.
|
||||
Сохраняй формат документа: .doc-page блоки (A4 страницы), обложку, секции, таблицы, doc-footer.
|
||||
Это СТАТИЧНЫЙ документ — никаких form, input, textarea, кнопок.
|
||||
СТИЛЬ — как Word: без border-radius, таблицы с центрированным текстом и тёмным заголовком.`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Текущий HTML:\n\n${html}\n\nВнеси правку: ${message}`
|
||||
}
|
||||
],
|
||||
max_tokens: 16000,
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
let newHtml = response.choices[0].message.content;
|
||||
newHtml = newHtml.replace(/^```html?\n?/i, '').replace(/\n?```$/i, '').trim();
|
||||
res.json({ ok: true, html: newHtml });
|
||||
} catch (err) {
|
||||
console.error('Refine error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Transcribe audio via Deepgram
|
||||
async function transcribeAudio(buffer, mime) {
|
||||
const dgRes = await fetch(
|
||||
'https://api.deepgram.com/v1/listen?model=nova-2&language=ru&smart_format=true&punctuate=true',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Token ' + DEEPGRAM_KEY,
|
||||
'Content-Type': mime || 'audio/webm',
|
||||
},
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
if (!dgRes.ok) {
|
||||
const errText = await dgRes.text();
|
||||
throw new Error('Deepgram error: ' + dgRes.status + ' ' + errText);
|
||||
}
|
||||
const dgData = await dgRes.json();
|
||||
return dgData.results?.channels?.[0]?.alternatives?.[0]?.transcript || '';
|
||||
}
|
||||
|
||||
// Extract meeting fields from transcript via GPT
|
||||
const EXTRACT_SYSTEM_PROMPT = `Ты — парсер для Summary Builder (генератор саммари встреч).
|
||||
|
||||
Тебе дают текст — это ЗАПИСЬ ВСТРЕЧИ или ДИАЛОГ.
|
||||
Проанализируй и извлеки метаданные.
|
||||
|
||||
КРИТИЧЕСКОЕ ПРАВИЛО — ПУСТЫЕ ПОЛЯ:
|
||||
Если ТОЧНОЕ значение НЕ прозвучало в тексте — поле ОБЯЗАНО быть "".
|
||||
ЗАПРЕЩЕНО выдумывать, угадывать или подставлять общие слова.
|
||||
|
||||
Верни ТОЛЬКО JSON без markdown:
|
||||
{
|
||||
"meetingTitle": "Тема встречи — краткое название" или "",
|
||||
"participants": "Имена участников через запятую" или "",
|
||||
"meetingType": "одно из: общая, продажа, брифинг, интервью, стратегия",
|
||||
"context": "Подробный контекст — 3-5 предложений о чём встреча, цели, ключевые моменты",
|
||||
"keyTopics": ["Тема 1", "Тема 2", "Тема 3"]
|
||||
}
|
||||
|
||||
ВАЖНО:
|
||||
- meetingType определяй по содержанию: если обсуждают покупку/продажу/цены → "продажа", если требования к проекту → "брифинг", если вопрос-ответ → "интервью", если планы/стратегия → "стратегия", иначе → "общая"
|
||||
- keyTopics — основные темы, которые обсуждались (3-7 штук)
|
||||
- context — МАКСИМАЛЬНО подробно, что обсуждали, кто что сказал, какие проблемы/решения
|
||||
- Правило: не знаешь точное значение → пустая строка ""`;
|
||||
|
||||
async function extractFieldsFromText(text, currentFields) {
|
||||
let inputText = text;
|
||||
if (text.length > 12000) {
|
||||
const summary = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{ role: 'system', content: 'Суммаризируй транскрипт встречи ПОДРОБНО. Сохрани: имена участников, темы, решения, действия, возражения, цитаты, даты, цифры. 2000-3000 слов.' },
|
||||
{ role: 'user', content: text.substring(0, 60000) }
|
||||
],
|
||||
temperature: 0.2,
|
||||
max_tokens: 4000,
|
||||
});
|
||||
inputText = summary.choices[0].message.content;
|
||||
}
|
||||
|
||||
let userContent = inputText;
|
||||
if (currentFields) {
|
||||
userContent = `ТЕКУЩЕЕ СОСТОЯНИЕ ПОЛЕЙ:\n${currentFields}\n\nНОВЫЙ ВВОД (дополни/обнови):\n${inputText}`;
|
||||
}
|
||||
|
||||
const extraction = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{ role: 'system', content: EXTRACT_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userContent }
|
||||
],
|
||||
temperature: 0.2,
|
||||
max_tokens: 1500,
|
||||
});
|
||||
|
||||
let content = extraction.choices[0].message.content;
|
||||
content = content.replace(/^```json?\n?/i, '').replace(/\n?```$/i, '').trim();
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
// Transcribe only (chat mic)
|
||||
app.post('/api/transcribe', requireAuth, uploadSmall.single('audio'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'No audio' });
|
||||
const text = await transcribeAudio(req.file.buffer, req.file.mimetype);
|
||||
res.json({ ok: true, text });
|
||||
} catch (err) {
|
||||
console.error('Transcribe error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Voice fill — mic -> Deepgram -> GPT field extraction
|
||||
app.post('/api/voice-fill', requireAuth, uploadSmall.single('audio'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'No audio file' });
|
||||
const currentFields = req.body.currentFields || null;
|
||||
const text = await transcribeAudio(req.file.buffer, req.file.mimetype);
|
||||
console.log('Voice transcript:', text.substring(0, 200));
|
||||
|
||||
let parsed;
|
||||
try { parsed = await extractFieldsFromText(text, currentFields); }
|
||||
catch (e) {
|
||||
console.error('Extract fields error (voice):', e.message);
|
||||
return res.json({ ok: true, transcript: text, fields: null });
|
||||
}
|
||||
|
||||
res.json({ ok: true, transcript: text, fields: parsed });
|
||||
} catch (err) {
|
||||
console.error('Voice fill error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// File fill — upload audio/video/text -> transcribe -> extract
|
||||
app.post('/api/file-fill', requireAuth, uploadLarge.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file' });
|
||||
const currentFields = req.body.currentFields || null;
|
||||
const mime = req.file.mimetype;
|
||||
const name = req.file.originalname.toLowerCase();
|
||||
let transcript = '';
|
||||
|
||||
if (mime === 'text/plain' || name.endsWith('.txt')) {
|
||||
transcript = req.file.buffer.toString('utf-8');
|
||||
} else {
|
||||
console.log('Sending to Deepgram:', mime, req.file.size, 'bytes');
|
||||
transcript = await transcribeAudio(req.file.buffer, mime);
|
||||
}
|
||||
|
||||
if (!transcript.trim()) {
|
||||
return res.json({ ok: false, error: 'Не удалось получить текст из файла' });
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try { parsed = await extractFieldsFromText(transcript, currentFields); }
|
||||
catch (e) {
|
||||
console.error('Extract fields error:', e.message);
|
||||
return res.json({ ok: true, transcript: transcript.substring(0, 500), fields: null });
|
||||
}
|
||||
|
||||
res.json({ ok: true, transcript: transcript.substring(0, 500), fields: parsed });
|
||||
} catch (err) {
|
||||
console.error('File fill error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Random slug
|
||||
function randomSlug() {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const part = (len) => Array.from({ length: len }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
return `${part(3)}-${part(4)}-${part(3)}`;
|
||||
}
|
||||
|
||||
function isSlugFree(name) {
|
||||
return !fs.existsSync(path.join(SITES_DIR, name));
|
||||
}
|
||||
|
||||
app.get('/api/check-slug', requireAuth, (req, res) => {
|
||||
const name = (req.query.name || '').replace(/[^a-z0-9-]/gi, '').toLowerCase();
|
||||
if (!name) return res.json({ ok: true, free: false });
|
||||
res.json({ ok: true, free: isSlugFree(name), url: `summary.edemium.ru/${name}` });
|
||||
});
|
||||
|
||||
// Deploy — save HTML to /sites/summaries/{slug}/index.html
|
||||
app.post('/api/deploy', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { html } = req.body;
|
||||
if (!html) return res.status(400).json({ error: 'Missing html' });
|
||||
|
||||
let name = (req.body.slug || '').replace(/[^a-z0-9-]/gi, '').toLowerCase();
|
||||
if (!name) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
name = randomSlug();
|
||||
if (isSlugFree(name)) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSlugFree(name)) {
|
||||
return res.status(400).json({ error: `summary.edemium.ru/${name} уже занят` });
|
||||
}
|
||||
|
||||
const siteDir = path.join(SITES_DIR, name);
|
||||
fs.mkdirSync(siteDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(siteDir, 'index.html'), html, 'utf-8');
|
||||
|
||||
const url = `https://summary.edemium.ru/${name}`;
|
||||
res.json({ ok: true, url, name });
|
||||
} catch (err) {
|
||||
console.error('Deploy error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// List deployed summaries
|
||||
app.get('/api/summaries', requireAuth, (req, res) => {
|
||||
try {
|
||||
const dirs = fs.readdirSync(SITES_DIR)
|
||||
.filter(d => {
|
||||
const full = path.join(SITES_DIR, d);
|
||||
return fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, 'index.html'));
|
||||
})
|
||||
.map(d => ({ name: d, url: `https://summary.edemium.ru/${d}` }));
|
||||
res.json({ ok: true, summaries: dirs });
|
||||
} catch (err) {
|
||||
res.json({ ok: true, summaries: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// Download as .docx
|
||||
app.post('/api/download-docx', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { html } = req.body;
|
||||
if (!html) return res.status(400).json({ error: 'No HTML' });
|
||||
|
||||
const HTMLtoDOCX = require('html-to-docx');
|
||||
const docxBuffer = await HTMLtoDOCX(html, null, {
|
||||
table: { row: { cantSplit: true } },
|
||||
footer: true,
|
||||
pageNumber: true,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="summary.docx"');
|
||||
res.send(Buffer.from(docxBuffer));
|
||||
} catch (err) {
|
||||
console.error('DOCX error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Telegram notification on generation
|
||||
function notifyTelegram(title, type) {
|
||||
if (!BOT_TOKEN || !CHAT_ID) return;
|
||||
const text = `📝 <b>Новое саммари</b>\n├ 📋 ${title}\n└ 🏷 ${type}`;
|
||||
fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chat_id: CHAT_ID, text, parse_mode: 'HTML' }),
|
||||
}).catch(e => console.error('TG notify error:', e.message));
|
||||
}
|
||||
|
||||
// Static files
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Serve published summaries
|
||||
app.get('/:slug', (req, res) => {
|
||||
const slug = req.params.slug.replace(/[^a-z0-9-]/gi, '').toLowerCase();
|
||||
const file = path.join(SITES_DIR, slug, 'index.html');
|
||||
if (fs.existsSync(file)) {
|
||||
res.sendFile(path.resolve(file));
|
||||
} else {
|
||||
res.status(404).send('Summary not found');
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Summary Builder running on port ${PORT}`);
|
||||
});
|
||||
173
template-meeting.html
Normal file
173
template-meeting.html
Normal file
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Саммари встречи — {ТЕМА}</title>
|
||||
<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:#f0f1f3;--white:#fff;--text:#1a1a2e;--sub:#6b7280;
|
||||
--accent:#2563eb;--accent-dark:#1e40af;
|
||||
--border:#d1d5db;--green:#10b981;
|
||||
}
|
||||
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);line-height:1.7}
|
||||
|
||||
.doc-page{
|
||||
width:210mm;max-width:100%;min-height:297mm;
|
||||
background:var(--white);margin:24px auto;padding:50px 60px;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.08);
|
||||
position:relative;
|
||||
}
|
||||
|
||||
.cover-page{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}
|
||||
.cover-badge{font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--accent);font-weight:700;margin-bottom:24px}
|
||||
.cover-line{width:60px;height:3px;background:var(--accent);margin:0 auto 32px}
|
||||
.cover-page h1{font-size:28px;font-weight:800;line-height:1.3;margin-bottom:12px;color:var(--text)}
|
||||
.cover-page .subtitle{font-size:15px;color:var(--sub);margin-bottom:48px;max-width:500px;line-height:1.6}
|
||||
.cover-page .meeting-meta{margin-top:40px;font-size:14px;color:var(--text);line-height:2.2}
|
||||
.cover-page .meeting-meta b{color:var(--accent)}
|
||||
.cover-page .date{font-size:13px;color:var(--sub);margin-top:48px}
|
||||
|
||||
.section{margin-bottom:32px}
|
||||
.section-title{font-size:17px;font-weight:800;color:var(--accent);margin-bottom:6px;padding-bottom:6px;border-bottom:2px solid var(--accent)}
|
||||
.section p{margin-bottom:10px;font-size:14px;line-height:1.8}
|
||||
|
||||
.table-wrap{margin:14px 0}
|
||||
table{width:100%;border-collapse:collapse;border:1px solid var(--border)}
|
||||
table th{background:var(--accent-dark);color:#fff;padding:10px 14px;text-align:center;font-size:12px;font-weight:700;border:1px solid var(--accent-dark)}
|
||||
table td{padding:10px 14px;border:1px solid var(--border);font-size:13px;text-align:center}
|
||||
table td:first-child{text-align:left}
|
||||
|
||||
.sub-title{font-size:14px;font-weight:700;margin:18px 0 8px;color:var(--text)}
|
||||
|
||||
.checklist{list-style:none;padding:0;margin:10px 0}
|
||||
.checklist li{padding:4px 0;font-size:13px;line-height:1.7}
|
||||
.checklist li::before{content:'✓ ';color:var(--green);font-weight:700}
|
||||
|
||||
.highlight-box{background:#eff6ff;border-left:3px solid var(--accent);padding:12px 16px;margin:12px 0;font-size:13px;line-height:1.7}
|
||||
|
||||
.quote-item{padding:10px 0;border-bottom:1px solid var(--border);font-size:13px;line-height:1.7}
|
||||
.quote-item:last-child{border-bottom:none}
|
||||
.quote-text{font-style:italic;color:var(--sub)}
|
||||
.quote-author{font-weight:600;color:var(--text);margin-top:4px;font-size:12px}
|
||||
|
||||
.action-status{display:inline-block;padding:2px 8px;font-size:11px;font-weight:600;border-radius:3px}
|
||||
.status-urgent{background:#fef2f2;color:#dc2626}
|
||||
.status-normal{background:#eff6ff;color:#2563eb}
|
||||
.status-done{background:#ecfdf5;color:#059669}
|
||||
|
||||
.doc-footer{text-align:center;padding:24px 0;color:var(--sub);font-size:11px;border-top:1px solid var(--border);margin-top:32px}
|
||||
|
||||
@media(max-width:600px){
|
||||
.doc-page{margin:8px;padding:24px 20px;min-height:auto}
|
||||
.cover-page h1{font-size:22px}
|
||||
}
|
||||
@media print{
|
||||
body{background:#fff}
|
||||
.doc-page{box-shadow:none;margin:0;padding:40px 50px;width:100%}
|
||||
.cover-page{min-height:100vh;page-break-after:always}
|
||||
.section{page-break-inside:avoid}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- COVER PAGE -->
|
||||
<div class="doc-page cover-page">
|
||||
<div class="cover-badge">САММАРИ ВСТРЕЧИ</div>
|
||||
<div class="cover-line"></div>
|
||||
<h1>{Тема встречи}</h1>
|
||||
<div class="subtitle">{Краткое описание — 1-2 предложения о чём была встреча}</div>
|
||||
<div class="meeting-meta">
|
||||
<b>Дата:</b> {Дата}<br>
|
||||
<b>Участники:</b> {Список участников}<br>
|
||||
<b>Тип:</b> {Тип встречи}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTENT PAGE 1 -->
|
||||
<div class="doc-page">
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Контекст встречи</div>
|
||||
<p>{О чём встреча — 2-3 предложения с описанием цели, предыстории и ожиданий}</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Ключевые темы</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<tr><th>Тема</th><th>Суть обсуждения</th></tr>
|
||||
<tr><td>{Тема 1}</td><td>{Что обсуждали}</td></tr>
|
||||
<tr><td>{Тема 2}</td><td>{Что обсуждали}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Решения</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<tr><th>Решение</th><th>Ответственный</th><th>Комментарий</th></tr>
|
||||
<tr><td>{Решение}</td><td>{Кто}</td><td>{Детали}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Действия</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<tr><th>Задача</th><th>Исполнитель</th><th>Срок</th></tr>
|
||||
<tr><td>{Задача}</td><td>{Кто}</td><td>{Когда}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- CONTENT PAGE 2 (optional — only if relevant) -->
|
||||
<div class="doc-page">
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Возражения / Вопросы</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<tr><th>Вопрос / Возражение</th><th>Кто озвучил</th><th>Комментарий / Ответ</th></tr>
|
||||
<tr><td>{Вопрос}</td><td>{Кто}</td><td>{Ответ}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Ключевые цитаты</div>
|
||||
<div class="quote-item">
|
||||
<div class="quote-text">"— {Цитата}"</div>
|
||||
<div class="quote-author">{Кто сказал}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Следующие шаги</div>
|
||||
<ol style="padding-left:20px;line-height:2.2;font-size:13px">
|
||||
<li>{Шаг 1}</li>
|
||||
<li>{Шаг 2}</li>
|
||||
<li>{Шаг 3}</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Рекомендации</div>
|
||||
<ul class="checklist">
|
||||
<li>{Рекомендация 1}</li>
|
||||
<li>{Рекомендация 2}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="doc-footer">Сгенерировано через summary.edemium.ru</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user