Дизайн десктопного клиента: 3 варианта (sidebar / voice-first / three-pane)

This commit is contained in:
Porfiry
2026-09-18 23:57:14 +03:00
parent d62f7da3f6
commit a515008e19
7 changed files with 1170 additions and 2 deletions
+342
View File
@@ -0,0 +1,342 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Вариант 2 — Rail / Voice-first</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0E1418; --panel: #141E24; --panel2: #1B2830; --line: #22323C;
--fg: #E8EEF2; --dim: #7A93A3; --accent: #4FD1C5; --hot: #FF6B6B;
--bot-bubble: #1B2830; --user-bubble: #1F4B54;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #070B0D; color: var(--fg); line-height: 1.45;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px;
}
.window {
width: 1240px; height: 750px; background: var(--bg); border-radius: 14px; display: flex;
overflow: hidden; border: 1px solid var(--line); box-shadow: 0 24px 80px rgba(0,0,0,.65);
}
/* ---- узкая колонка-рейка: чаты иконками ---- */
.rail {
width: 76px; flex: 0 0 76px; background: var(--panel); border-right: 1px solid var(--line);
display: flex; flex-direction: column; align-items: center; padding: 12px 0; gap: 10px;
}
.rail-btn {
width: 46px; height: 46px; border-radius: 13px; border: none; background: var(--panel2);
color: var(--dim); cursor: pointer; display: grid; place-items: center; font-size: 17px;
position: relative; transition: .15s;
}
.rail-btn:hover { color: var(--fg); }
.rail-btn.active { background: var(--accent); color: #07201D; }
.rail-btn .dot-badge {
position: absolute; top: -3px; right: -3px; background: var(--accent); color: #07201D;
font-size: 10px; font-weight: 800; border-radius: 8px; padding: 1px 5px; min-width: 16px;
}
.rail-btn .label {
position: absolute; bottom: -14px; font-size: 9px; color: var(--dim); white-space: nowrap;
}
.rail-sep { width: 28px; height: 1px; background: var(--line); margin: 8px 0; }
.rail-spacer { flex: 1; }
/* ---- средний столбец: список диалогов ---- */
.list { width: 296px; flex: 0 0 296px; background: var(--panel); border-right: 1px solid var(--line);
display: flex; flex-direction: column; }
.l-head { padding: 16px 18px 12px; }
.l-head h1 { font-size: 16.5px; font-weight: 650; letter-spacing: .2px; }
.l-head p { font-size: 11.5px; color: var(--dim); margin-top: 3px; }
.search {
margin: 0 18px 10px; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 13px; outline: none;
}
.search:focus { border-color: var(--accent); }
.search::placeholder { color: var(--dim); }
.rows { flex: 1; overflow-y: auto; padding: 0 10px 12px; }
.row {
display: flex; gap: 11px; padding: 10px 11px; border-radius: 11px; cursor: pointer;
border-left: 2px solid transparent;
}
.row:hover { background: var(--panel2); }
.row.active { background: var(--panel2); border-left-color: var(--accent); }
.row .av {
width: 38px; height: 38px; flex: 0 0 38px; border-radius: 11px; display: grid; place-items: center;
font-size: 13px; font-weight: 700; background: rgba(79,209,197,.16); color: var(--accent);
}
.row .mid { flex: 1; min-width: 0; }
.row .nm { font-size: 13.5px; font-weight: 550; display: flex; justify-content: space-between; }
.row .tm { font-size: 10.5px; color: var(--dim); font-weight: 400; }
.row .pv { font-size: 12px; color: var(--dim); white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; margin-top: 2px; }
/* ---- основная зона чата ---- */
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.m-head { height: 60px; flex: 0 0 60px; display: flex; align-items: center; gap: 12px;
padding: 0 22px; border-bottom: 1px solid var(--line); }
.m-head .t { font-size: 14.5px; font-weight: 600; }
.m-head .s { font-size: 11.5px; color: var(--dim); }
.ghost { margin-left: auto; display: flex; gap: 6px; }
.ghost button { background: transparent; border: 1px solid var(--line); color: var(--dim);
border-radius: 8px; padding: 5px 11px; font-size: 11.5px; cursor: pointer; }
.ghost button:hover { color: var(--fg); border-color: var(--dim); }
.thread { flex: 1; overflow-y: auto; padding: 22px 26px; display: flex; flex-direction: column; gap: 13px; }
.b { max-width: 76%; padding: 10px 14px; border-radius: 14px; font-size: 13.5px; }
.b.u { align-self: flex-end; background: var(--user-bubble); border-bottom-right-radius: 4px; }
.b.a { align-self: flex-start; background: var(--bot-bubble); border-bottom-left-radius: 4px; }
.b .tm { font-size: 10.5px; color: var(--dim); margin-top: 5px; }
/* голосовое сообщение — с волной прямо в пузыре */
.voice-b {
align-self: flex-end; background: var(--user-bubble); border-radius: 14px 14px 4px 14px;
padding: 10px 14px; display: flex; align-items: center; gap: 11px; min-width: 250px;
}
.play { width: 30px; height: 30px; border-radius: 50%; background: var(--accent); color: #07201D;
border: none; display: grid; place-items: center; cursor: pointer; font-size: 12px; }
.vwave { flex: 1; display: flex; align-items: center; gap: 2px; height: 24px; }
.vwave i { flex: 1; background: rgba(232,238,242,.5); border-radius: 2px; }
.vdur { font-size: 11px; color: var(--dim); font-variant-numeric: tabular-nums; }
/* ---- КОМПОЗЕР: голос — главный ---- */
.composer { border-top: 1px solid var(--line); background: var(--panel); padding: 16px 22px 14px; }
.mic-zone { display: flex; align-items: center; gap: 16px; }
.mic-big {
width: 58px; height: 58px; flex: 0 0 58px; border-radius: 50%; border: none; cursor: pointer;
background: var(--accent); color: #07201D; font-size: 23px; display: grid; place-items: center;
transition: .2s;
}
.mic-big:hover { transform: scale(1.05); }
.mic-big.on { background: var(--hot); color: #fff; animation: ring 1.5s infinite; }
@keyframes ring { 0%,100% { box-shadow: 0 0 0 0 rgba(255,107,107,.6); } 50% { box-shadow: 0 0 0 13px rgba(255,107,107,0); } }
.mic-side { flex: 1; min-width: 0; }
.mic-hint { font-size: 12px; color: var(--dim); margin-bottom: 7px; }
.mic-hint b { color: var(--fg); font-weight: 550; }
.type-row { display: flex; align-items: center; gap: 9px; }
.type-row textarea {
flex: 1; background: var(--bg); border: 1px solid var(--line); border-radius: 10px; padding: 10px 13px;
color: var(--fg); font-family: inherit; font-size: 13.5px; outline: none; resize: none;
height: 40px; max-height: 110px; line-height: 1.4;
}
.type-row textarea:focus { border-color: var(--accent); }
.type-row textarea::placeholder { color: var(--dim); }
.go { height: 40px; padding: 0 17px; border-radius: 10px; border: 1px solid var(--line);
background: transparent; color: var(--dim); font-size: 12.5px; cursor: pointer; }
.go.active { background: var(--accent); border-color: var(--accent); color: #07201D; font-weight: 650; }
/* расшифровка в реальном времени */
.live-asr { margin-top: 11px; display: none; background: var(--bg); border: 1px solid var(--hot);
border-radius: 11px; padding: 11px 14px; }
.live-asr.on { display: block; }
.live-asr .top { display: flex; align-items: center; gap: 9px; margin-bottom: 8px; font-size: 11.5px; color: var(--hot); font-weight: 600; }
.blink { width: 7px; height: 7px; border-radius: 50%; background: var(--hot); animation: bl .9s infinite; }
@keyframes bl { 50% { opacity: .15; } }
.big-wave { display: flex; align-items: center; gap: 3px; height: 30px; margin-bottom: 8px; }
.big-wave i { width: 3px; background: var(--accent); border-radius: 2px; height: 25%; animation: bw .8s infinite ease-in-out; }
.big-wave i:nth-child(2n) { animation-delay: .1s; } .big-wave i:nth-child(3n) { animation-delay: .2s; }
@keyframes bw { 0%,100% { height: 18%; } 50% { height: 100%; } }
.asr-line { font-size: 14px; color: var(--fg); min-height: 20px; }
.asr-line .cursor { display: inline-block; width: 2px; height: 15px; background: var(--accent);
vertical-align: middle; animation: bl .8s infinite; }
.live-actions { display: flex; gap: 8px; margin-top: 11px; }
.live-actions button { border-radius: 9px; padding: 7px 14px; font-size: 12.5px; cursor: pointer; border: 1px solid var(--line); background: transparent; color: var(--dim); }
.live-actions .prim { background: var(--accent); border-color: var(--accent); color: #07201D; font-weight: 620; }
.live-actions .dang { color: var(--hot); }
.foot-note { font-size: 10.5px; color: var(--dim); margin-top: 9px; }
kbd { background: var(--panel2); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; font-size: 10px; }
/* окно целиком влезает на любой экран — не режем картинку */
@media (max-height: 820px) { body { padding: 8px; } .window, .win { transform: scale(.78); } }
@media (max-height: 700px) { .window, .win { transform: scale(.66); } }
@media (max-height: 600px) { .window, .win { transform: scale(.56); } }
</style>
</head>
<body>
<div class="window">
<!-- ============ РЕЙКА ============ -->
<nav class="rail">
<button class="rail-btn active" title="Диалоги">💬<span class="dot-badge">3</span></button>
<button class="rail-btn" title="Память агента">🧠</button>
<button class="rail-btn" title="Инструменты">⚙</button>
<div class="rail-sep"></div>
<button class="rail-btn" title="Новый диалог">+</button>
<div class="rail-spacer"></div>
<button class="rail-btn" title="Настройки">⚙︎</button>
</nav>
<!-- ============ СПИСОК ДИАЛОГОВ ============ -->
<section class="list">
<div class="l-head">
<h1>Диалоги</h1>
<p>локальный агент · 3 непрочитанных</p>
</div>
<input class="search" placeholder="Поиск…">
<div class="rows">
<div class="row active">
<div class="av">АГ</div>
<div class="mid">
<div class="nm"><span>agentik</span><span class="tm">14:22</span></div>
<div class="pv">Собрал отчёт, жду правок</div>
</div>
</div>
<div class="row">
<div class="av">ОЧ</div>
<div class="mid">
<div class="nm"><span>Отчёт за квартал</span><span class="tm">12:05</span></div>
<div class="pv">Файл лежит на диске</div>
</div>
</div>
<div class="row">
<div class="av">РЗ</div>
<div class="mid">
<div class="nm"><span>Разбор логов</span><span class="tm">вчера</span></div>
<div class="pv">Нашёл 3 ошибки 502</div>
</div>
</div>
<div class="row">
<div class="av">ПО</div>
<div class="mid">
<div class="nm"><span>Solana-бэкенд</span><span class="tm">вчера</span></div>
<div class="pv">Девайс-нет стартовал</div>
</div>
</div>
<div class="row">
<div class="av">КН</div>
<div class="mid">
<div class="nm"><span>Книги</span><span class="tm">пн</span></div>
<div class="pv">Разложил по полкам</div>
</div>
</div>
</div>
</section>
<!-- ============ ЧАТ ============ -->
<main class="main">
<header class="m-head">
<div>
<div class="t">agentik</div>
<div class="s">на связи · codding-big</div>
</div>
<div class="ghost">
<button>Стоп</button>
<button>Очистить</button>
</div>
</header>
<div class="thread" id="thread">
<div class="b u">Посмотри логи за вчера<span class="tm">14:10</span></div>
<div class="b a">Проверил: 3 ошибки 502 около 04:20, все — обрыв соединения с upstream. Виновник — таймаут прокси. Поправить?<span class="tm">14:12</span></div>
<div class="voice-b">
<button class="play" onclick="togglePlay(this)">▶</button>
<div class="vwave" id="pastWave"></div>
<span class="vdur">0:07</span>
</div>
<div class="b a">Понял: увеличить таймаут до 60 секунд и перезапустить прокси. Сделал — прокси поднят заново, ошибок за последний час нет.<span class="tm">14:20</span></div>
</div>
<div class="composer">
<div class="live-asr" id="liveAsr">
<div class="top"><span class="blink"></span> ЗАПИСЬ · <span id="liveTime">0:00</span></div>
<div class="big-wave" id="bigWave"></div>
<div class="asr-line"><span id="asrLine"></span><span class="cursor"></span></div>
<div class="live-actions">
<button class="prim" onclick="stopRec(false)">Отправить</button>
<button onclick="insertAsText()">В поле ввода</button>
<button class="dang" onclick="stopRec(true)">Отменить</button>
</div>
</div>
<div class="mic-zone">
<button class="mic-big" id="micBtn" title="Запись (Ctrl+R, удержание пробела)">🎙</button>
<div class="mic-side">
<div class="mic-hint"><b>Говорите</b> — текст появится в поле ниже. Можно править перед отправкой.</div>
<div class="type-row">
<textarea id="ta" placeholder="Или напишите руками…"></textarea>
<button class="go" id="goBtn" disabled>Отправить</button>
</div>
</div>
</div>
<div class="foot-note">
<kbd>Ctrl</kbd>+<kbd>R</kbd> — начать/стоп · <kbd>Пробел</kbd> (удержание) — говорить · <kbd>Enter</kbd> — отправить · <kbd>Esc</kbd> — отменить запись
</div>
</div>
</main>
</div>
<script>
const ta = document.getElementById('ta'), goBtn = document.getElementById('goBtn');
const micBtn = document.getElementById('micBtn'), liveAsr = document.getElementById('liveAsr');
const thread = document.getElementById('thread');
document.getElementById('pastWave').innerHTML =
Array.from({length: 34}, () => '<i style="height:' + (18 + Math.random()*80) + '%"></i>').join('');
document.getElementById('bigWave').innerHTML = Array.from({length: 40}, () => '<i></i>').join('');
ta.addEventListener('input', () => {
goBtn.disabled = ta.value.trim() === '';
goBtn.classList.toggle('active', ta.value.trim() !== '');
ta.style.height = '40px'; ta.style.height = Math.min(ta.scrollHeight, 110) + 'px';
});
function send() {
const t = ta.value.trim(); if (!t) return;
add(t, 'u'); ta.value = ''; ta.style.height = '40px';
goBtn.disabled = true; goBtn.classList.remove('active');
setTimeout(() => add('Выполняю: «' + t + '».', 'a'), 800);
}
function add(text, who) {
const d = document.createElement('div'); d.className = 'b ' + who;
const n = new Date();
d.innerHTML = text + '<span class="tm">' + n.getHours() + ':' + String(n.getMinutes()).padStart(2,'0') + '</span>';
thread.appendChild(d); thread.scrollTop = thread.scrollHeight;
}
goBtn.onclick = send;
ta.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } });
// ---- запись с живой расшифровкой ----
const PHRASE = 'посмотри логи за вчерашний день и скажи что там с ошибками';
let tmr = null, sec = 0, wordIdx = 0;
function startRec() {
sec = 0; wordIdx = 0;
liveAsr.classList.add('on'); micBtn.classList.add('on');
document.getElementById('asrLine').textContent = '';
tmr = setInterval(() => {
sec++;
document.getElementById('liveTime').textContent = '0:' + String(sec).padStart(2,'0');
const words = PHRASE.split(' ');
if (wordIdx < words.length) {
document.getElementById('asrLine').textContent = words.slice(0, ++wordIdx).join(' ');
}
}, 450);
}
function stopRec(cancel) {
clearInterval(tmr);
liveAsr.classList.remove('on'); micBtn.classList.remove('on');
if (cancel) return;
const text = document.getElementById('asrLine').textContent;
if (!text) return;
if (sec >= 2) { // короткие — сразу в чат, длинные — в поле
add(text, 'u');
setTimeout(() => add('Записал голосом, разбираю…', 'a'), 700);
}
}
function insertAsText() {
ta.value = (ta.value ? ta.value + ' ' : '') + document.getElementById('asrLine').textContent;
ta.dispatchEvent(new Event('input')); ta.focus(); stopRec(true);
}
function togglePlay(btn) { btn.textContent = btn.textContent === '▶' ? '❚❚' : '▶'; }
micBtn.onclick = () => liveAsr.classList.contains('on') ? stopRec(false) : startRec();
document.addEventListener('keydown', e => {
if (e.ctrlKey && e.key.toLowerCase() === 'r') { e.preventDefault(); micBtn.click(); }
if (e.key === 'Escape' && liveAsr.classList.contains('on')) stopRec(true);
});
</script>
</body>
</html>