Дизайн десктопного клиента: 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
+362
View File
@@ -0,0 +1,362 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Вариант 1 — Sidebar / Utility</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #121218; --panel: #17212B; --panel2: #1C2733; --line: #232B36;
--fg: #EBEBEB; --dim: #768C9E; --accent: #6AB2F2; --badge: #5EB5F7;
--err: #EC3942; --user-bubble: #2B5278; --bot-bubble: #1C2733;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #0A0A0F; color: var(--fg); line-height: 1.45;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px;
}
.window {
width: 1180px; height: 740px; background: var(--bg); border-radius: 12px;
display: flex; overflow: hidden; border: 1px solid var(--line);
box-shadow: 0 24px 80px rgba(0,0,0,.6);
}
/* ---------- левая колонка: список чатов ---------- */
.sidebar { width: 320px; background: var(--panel); border-right: 1px solid var(--line); display: flex; flex-direction: column; }
.sb-head { padding: 14px 16px 10px; }
.sb-title { font-size: 17px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
.icon-btn {
width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; display: grid; place-items: center; font-size: 17px;
}
.icon-btn:hover { background: var(--panel2); color: var(--fg); }
.search {
margin: 10px 16px 8px; padding: 8px 12px; border-radius: 9px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 13px; width: calc(100% - 32px); outline: none;
}
.search:focus { border-color: var(--accent); }
.search::placeholder { color: var(--dim); }
/* лента папок — как в мобильном клиенте */
.folders { display: flex; gap: 6px; padding: 6px 16px 10px; overflow-x: auto; }
.folder {
padding: 5px 11px; border-radius: 14px; font-size: 12px; color: var(--dim);
background: var(--panel2); cursor: pointer; white-space: nowrap; border: 1px solid transparent;
}
.folder.active { color: var(--fg); border-color: var(--accent); background: rgba(106,178,242,.12); }
.folder .cnt { color: var(--badge); font-weight: 600; margin-left: 4px; }
.chat-list { flex: 1; overflow-y: auto; padding: 4px 8px 12px; }
.chat-row {
display: flex; gap: 11px; padding: 9px 10px; border-radius: 10px; cursor: pointer; align-items: flex-start;
}
.chat-row:hover { background: var(--panel2); }
.chat-row.active { background: #233040; }
.avatar {
width: 42px; height: 42px; border-radius: 50%; flex: 0 0 42px; display: grid; place-items: center;
font-size: 15px; font-weight: 600; color: #0F1922; background: var(--accent);
}
.avatar.g2 { background: #86C99A; } .avatar.g3 { background: #C9A886; } .avatar.g4 { background: #B79AE0; }
.chat-mid { flex: 1; min-width: 0; }
.chat-name { font-size: 14px; font-weight: 500; display: flex; justify-content: space-between; gap: 8px; }
.chat-time { font-size: 11px; color: var(--dim); font-weight: 400; flex: 0 0 auto; }
.chat-prev { font-size: 12.5px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.badge {
background: var(--badge); color: #0F1922; border-radius: 10px; font-size: 11px; font-weight: 700;
padding: 1px 6px; min-width: 19px; text-align: center;
}
.row-tail { display: flex; flex-direction: column; align-items: flex-end; gap: 5px; flex: 0 0 auto; }
/* ---------- правая часть: чат ---------- */
.main { flex: 1; display: flex; flex-direction: column; }
.chat-head {
height: 58px; flex: 0 0 58px; border-bottom: 1px solid var(--line); display: flex; align-items: center;
gap: 11px; padding: 0 16px;
}
.chat-head .avatar { width: 36px; height: 36px; flex: 0 0 36px; font-size: 13px; }
.head-meta { flex: 1; }
.head-name { font-size: 14.5px; font-weight: 600; }
.head-sub { font-size: 11.5px; color: var(--dim); }
.head-sub .dot { color: #63C88A; }
.messages { flex: 1; overflow-y: auto; padding: 18px 24px; display: flex; flex-direction: column; gap: 12px; }
.msg { max-width: 74%; padding: 9px 13px; border-radius: 13px; font-size: 13.5px; }
.msg.user { align-self: flex-end; background: var(--user-bubble); border-bottom-right-radius: 4px; }
.msg.bot { align-self: flex-start; background: var(--bot-bubble); border-bottom-left-radius: 4px; }
.msg .meta { font-size: 10.5px; color: var(--dim); margin-top: 5px; }
/* блок «рассуждения» и «инструмент» — это специфика агента, не мессенджера */
.think {
align-self: flex-start; max-width: 80%; border-left: 2px solid var(--accent);
background: rgba(106,178,242,.07); padding: 8px 12px; border-radius: 0 10px 10px 0;
font-size: 12.5px; color: var(--dim); cursor: pointer;
}
.think b { color: var(--accent); font-weight: 600; }
.tool {
align-self: flex-start; max-width: 80%; background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: 8px 12px; font-size: 12.5px;
display: flex; align-items: center; gap: 9px;
}
.tool .gear { color: var(--accent); }
.tool code { color: var(--dim); font-size: 11.5px; }
/* ---------- панель ввода с кнопкой записи ---------- */
.composer { border-top: 1px solid var(--line); padding: 12px 16px 14px; background: var(--panel); }
.attach-strip { display: flex; gap: 8px; margin-bottom: 9px; }
.attach-strip:empty { display: none; }
.chip {
background: var(--panel2); border: 1px solid var(--line); border-radius: 8px;
padding: 5px 9px; font-size: 11.5px; color: var(--dim); display: flex; align-items: center; gap: 7px;
}
.chip .x { cursor: pointer; color: var(--err); font-weight: 700; }
.input-row { display: flex; align-items: flex-end; gap: 9px; }
.input-wrap { flex: 1; display: flex; align-items: flex-end; background: var(--bg); border: 1px solid var(--line);
border-radius: 11px; padding: 9px 12px; gap: 9px; }
.input-wrap:focus-within { border-color: var(--accent); }
textarea {
flex: 1; background: transparent; border: none; outline: none; resize: none; color: var(--fg);
font-family: inherit; font-size: 13.5px; line-height: 1.45; max-height: 120px; height: 22px;
}
textarea::placeholder { color: var(--dim); }
/* круглая кнопка записи */
.rec-btn {
width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--line); background: var(--bg);
color: var(--dim); cursor: pointer; display: grid; place-items: center; font-size: 17px; flex: 0 0 40px;
transition: all .18s;
}
.rec-btn:hover { color: var(--fg); border-color: var(--dim); }
.rec-btn.recording { background: var(--err); border-color: var(--err); color: #fff; animation: pulse 1.4s infinite; }
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(236,57,66,.55); } 50% { box-shadow: 0 0 0 9px rgba(236,57,66,0); } }
.send-btn {
height: 40px; padding: 0 18px; border-radius: 11px; border: none; background: var(--accent);
color: #0F1922; font-size: 13px; font-weight: 600; cursor: pointer;
}
.send-btn:disabled { background: var(--panel2); color: var(--dim); cursor: default; }
.hint { font-size: 11px; color: var(--dim); margin-top: 8px; display: flex; justify-content: space-between; }
kbd { background: var(--panel2); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; font-size: 10.5px; }
/* запись: полоса над полем */
.rec-panel { display: none; align-items: center; gap: 12px; margin-bottom: 10px;
background: var(--bg); border: 1px solid var(--err); border-radius: 11px; padding: 9px 13px; }
.rec-panel.on { display: flex; }
.wave { display: flex; align-items: center; gap: 3px; height: 22px; flex: 1; }
.wave i { width: 3px; background: var(--err); border-radius: 2px; height: 30%; animation: wv .9s infinite ease-in-out; }
.wave i:nth-child(2n) { animation-delay: .12s; } .wave i:nth-child(3n) { animation-delay: .24s; }
.wave i:nth-child(4n) { animation-delay: .36s; }
@keyframes wv { 0%,100% { height: 22%; } 50% { height: 100%; } }
.rec-time { font-variant-numeric: tabular-nums; font-size: 13px; color: var(--err); font-weight: 600; }
.rec-actions { display: flex; gap: 8px; }
.mini { border: 1px solid var(--line); background: var(--panel2); color: var(--fg); border-radius: 8px;
padding: 5px 11px; font-size: 12px; cursor: pointer; }
.mini.ok { background: var(--accent); border-color: var(--accent); color: #0F1922; font-weight: 600; }
.mini.cancel { color: var(--err); }
/* всплывающее состояние: расшифровка распознанного текста */
.asr-note {
display: none; align-items: center; gap: 8px; font-size: 11.5px; color: var(--dim);
margin-bottom: 8px; padding-left: 2px;
}
.asr-note.on { display: flex; }
.asr-note .live { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
/* окно целиком влезает на любой экран — не режем картинку */
@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">
<!-- ================= СПИСОК ЧАТОВ ================= -->
<aside class="sidebar">
<div class="sb-head">
<div class="sb-title">
<span>Диалоги</span>
<button class="icon-btn" title="Новый диалог">+</button>
</div>
</div>
<input class="search" placeholder="Поиск диалога…">
<div class="folders">
<div class="folder active">Все чаты <span class="cnt">3</span></div>
<div class="folder">Работа</div>
<div class="folder">Дом</div>
<div class="folder">+</div>
</div>
<div class="chat-list" id="chatList">
<div class="chat-row active">
<div class="avatar">АГ</div>
<div class="chat-mid">
<div class="chat-name"><span>agentik · рабочий</span><span class="chat-time">14:22</span></div>
<div class="chat-prev">Собрал отчёт, жду правок</div>
</div>
<div class="row-tail"><span class="badge">2</span></div>
</div>
<div class="chat-row">
<div class="avatar g2">ОЧ</div>
<div class="chat-mid">
<div class="chat-name"><span>Отчёт за квартал</span><span class="chat-time">12:05</span></div>
<div class="chat-prev">Файл лежит на диске</div>
</div>
</div>
<div class="chat-row">
<div class="avatar g3">РЗ</div>
<div class="chat-mid">
<div class="chat-name"><span>Разбор логов nginx</span><span class="chat-time">вчера</span></div>
<div class="chat-prev">Нашёл 3 ошибки 502</div>
</div>
</div>
<div class="chat-row">
<div class="avatar g4">ПО</div>
<div class="chat-mid">
<div class="chat-name"><span>Помощь по Solana</span><span class="chat-time">вчера</span></div>
<div class="chat-prev">Девайс-нет стартовал</div>
</div>
</div>
</div>
</aside>
<!-- ================= ЧАТ ================= -->
<main class="main">
<header class="chat-head">
<div class="avatar">АГ</div>
<div class="head-meta">
<div class="head-name">agentik · рабочий</div>
<div class="head-sub"><span class="dot">●</span> на связи · модель codding-big</div>
</div>
<button class="icon-btn" title="Параметры диалога">⋯</button>
</header>
<div class="messages" id="messages">
<div class="msg user">Проверь, почему падает CI в agentik<span class="meta">14:18</span></div>
<div class="think" onclick="this.querySelector('.rest').style.display = this.querySelector('.rest').style.display==='none'?'inline':'none'">
<b>Думает:</b> 42 прогона, красных 40. Смотрю имена тестов… <span class="rest">похоже на проблему кодировки при создании файлов классов.</span>
</div>
<div class="tool"><span class="gear">⚙</span> <code>grep -rnP 'fun `[^`]*[^\x00-\x7F]' --include='*.kt'</code></div>
<div class="msg bot">Нашёл причину: в именах тестов стоит длинное тире «—», а сборка работает в ASCII-кодировке. Отсюда падение компилятора. Починил, CI зелёный.<span class="meta">14:22</span></div>
<div class="msg user">Отлично. Сделай релиз «3»<span class="meta">14:23</span></div>
<div class="think" onclick="this.querySelector('.rest').style.display = this.querySelector('.rest').style.display==='none'?'inline':'none'">
<b>Думает:</b> создаю тег, запускаю публикацию… <span class="rest">проверю, что в Nexus появилась версия 3.</span>
</div>
</div>
<!-- ===== панель ввода ===== -->
<div class="composer">
<div class="attach-strip" id="chips"></div>
<div class="asr-note" id="asrNote">
<span class="live"></span> распознаю: <span id="asrText" style="color:var(--fg)"></span>
</div>
<div class="rec-panel" id="recPanel">
<button class="mini cancel" onclick="stopRec(true)">✕</button>
<div class="wave" id="wave"></div>
<span class="rec-time" id="recTime">0:00</span>
<div class="rec-actions">
<button class="mini ok" onclick="stopRec(false)">Остановить и отправить</button>
</div>
</div>
<div class="input-row" id="inputRow">
<div class="input-wrap">
<textarea id="ta" rows="1" placeholder="Написать агенту…"></textarea>
</div>
<button class="rec-btn" id="recBtn" title="Записать голосовое (Ctrl+R)">🎙</button>
<button class="send-btn" id="sendBtn" disabled>Отправить</button>
</div>
<div class="hint">
<span><kbd>Ctrl</kbd>+<kbd>R</kbd> — запись голоса · <kbd>Enter</kbd> — отправить · <kbd>Shift</kbd>+<kbd>Enter</kbd> — новая строка</span>
<span id="statusHint">готов</span>
</div>
</div>
</main>
</div>
<script>
const ta = document.getElementById('ta');
const sendBtn = document.getElementById('sendBtn');
const recBtn = document.getElementById('recBtn');
const recPanel = document.getElementById('recPanel');
const inputRow = document.getElementById('inputRow');
const messages = document.getElementById('messages');
const asrNote = document.getElementById('asrNote');
const asrText = document.getElementById('asrText');
const statusHint = document.getElementById('statusHint');
// волна из 28 столбиков
document.getElementById('wave').innerHTML = Array.from({length: 28}, () => '<i></i>').join('');
ta.addEventListener('input', () => {
ta.style.height = '22px';
ta.style.height = Math.min(ta.scrollHeight, 120) + 'px';
sendBtn.disabled = ta.value.trim() === '';
});
function send() {
const t = ta.value.trim(); if (!t) return;
addMsg(t, 'user');
ta.value = ''; ta.style.height = '22px'; sendBtn.disabled = true;
statusHint.textContent = 'агент думает…';
setTimeout(() => {
addMsg('Принято. Выполняю: «' + t + '» — покажу ход в этом же диалоге.', 'bot');
statusHint.textContent = 'готов';
}, 900);
}
function addMsg(text, who) {
const d = document.createElement('div');
d.className = 'msg ' + who;
const now = new Date();
d.innerHTML = text + '<span class="meta">' + now.getHours() + ':' + String(now.getMinutes()).padStart(2,'0') + '</span>';
messages.appendChild(d); messages.scrollTop = messages.scrollHeight;
}
sendBtn.onclick = send;
ta.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
// ---------- запись ----------
let recTimer = null, recSec = 0;
const WORDS = ['проверь','логи','сборки','за','вчерашний','день','и','сделай','вывод'];
function startRec() {
recSec = 0; recPanel.classList.add('on'); recBtn.classList.add('recording');
inputRow.style.opacity = '.45'; statusHint.textContent = 'запись…';
asrNote.classList.add('on'); asrText.textContent = '';
let i = 0;
recTimer = setInterval(() => {
recSec++;
document.getElementById('recTime').textContent = '0:' + String(recSec).padStart(2,'0');
if (i < WORDS.length && recSec % 1 === 0) { // живая расшифровка
asrText.textContent = WORDS.slice(0, ++i).join(' ') + (i < WORDS.length ? ' …' : '');
}
}, 1000);
}
function stopRec(cancel) {
clearInterval(recTimer);
recPanel.classList.remove('on'); recBtn.classList.remove('recording');
inputRow.style.opacity = '1'; asrNote.classList.remove('on');
if (cancel) { statusHint.textContent = 'запись отменена'; return; }
if (recSec < 1) { statusHint.textContent = 'слишком коротко'; return; }
ta.value = WORDS.join(' ') + '.'; // распознанный текст попадает в поле
ta.dispatchEvent(new Event('input'));
statusHint.textContent = 'распознано, ' + recSec + ' с — проверь и отправь';
ta.focus();
}
recBtn.onclick = () => recPanel.classList.contains('on') ? stopRec(false) : startRec();
document.addEventListener('keydown', e => {
if (e.ctrlKey && e.key.toLowerCase() === 'r') { e.preventDefault(); recBtn.click(); }
});
</script>
</body>
</html>