Дизайн десктопного клиента: 3 варианта (sidebar / voice-first / three-pane)
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Вариант 1 — Sidebar / Utility
|
||||
|
||||
## Суть
|
||||
Привычный мессенджер: одна колонка диалогов слева, чат справа. Ничего лишнего —
|
||||
сделано так, чтобы не пришлось учиться заново.
|
||||
|
||||
## Ключевые решения
|
||||
- **Раскладка:** список диалогов (320) + чат. Плюс лента папок, как в мобильном клиенте.
|
||||
- **Подача работы агента:** «думает» — свёрнутый блок с левой цветной полосой, вызов
|
||||
инструмента — серая плашка с командой. Видно, но не мешает.
|
||||
- **Кнопка записи:** круглая, рядом с полем ввода. При записи — панель над полем
|
||||
с волной, таймером и «Остановить и отправить».
|
||||
- **Куда попадает распознанное:** в поле ввода. Проверил глазами — потом отправил.
|
||||
|
||||
## Что хорошо
|
||||
- Меньше всего удивляет; подходит как «рабочая лошадь» на каждый день.
|
||||
- Кнопка отправки крупная и явная.
|
||||
|
||||
## Что слабо
|
||||
- Работа агента показана бедно — видно только то, что попало в переписку.
|
||||
- При большом числе диалогов одна колонка тесновата.
|
||||
|
||||
## Кому
|
||||
Тому, кто хочет чат с агентом, а не пульт управления агентом.
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user