Дизайн десктопного клиента: 3 варианта (sidebar / voice-first / three-pane)
This commit is contained in:
@@ -1,3 +1,48 @@
|
||||
# agentik-desktop
|
||||
# agentik-desktop — дизайн десктопного клиента
|
||||
|
||||
Десктопный клиент агента agentik (Jetpack Compose Desktop). Ходит в :client-библиотеку. Голосовое управление.
|
||||
Десктопный клиент агента **agentik** (Jetpack Compose Desktop). UI общается с агентом
|
||||
через опубликованную библиотеку `pw.binom.agentik:client` — она даёт готовые
|
||||
`Agent` / `Conversation`, живой поток событий и `interrupt()` для кнопки «Стоп».
|
||||
|
||||
Это пока **только дизайн**, не код приложения. Три варианта лежат рядом, чтобы
|
||||
сравнить и выбрать один; после выбора лишние удаляются.
|
||||
|
||||
## Как смотреть
|
||||
|
||||
Открыть в браузере любой из трёх файлов — каждый самодостаточный, без сборки:
|
||||
|
||||
```
|
||||
sketches/001-sidebar-utility/index.html # привычный мессенджер
|
||||
sketches/002-rail-voice-first/index.html # голос — главное действие
|
||||
sketches/003-three-pane-command/index.html # три панели + состояние агента
|
||||
```
|
||||
|
||||
Кнопка микрофона работает: клик — запись, идёт волна и живая расшифровка,
|
||||
«Остановить и отправить» кладёт текст в поле. Так видно, как это будет ощущаться.
|
||||
|
||||
## Чем отличаются
|
||||
|
||||
| | 1. Sidebar | 2. Voice-first | 3. Command center |
|
||||
|---|---|---|---|
|
||||
| Экран | список + чат | рейка + список + чат | три панели |
|
||||
| Голос | круглая кнопка у поля | большая кнопка, живая расшифровка | компактная, строка записи снизу |
|
||||
| Работа агента | видна в переписке | в переписке | отдельные сворачиваемые шаги |
|
||||
| Кому | просто чат | кто диктует | кто следит за расходом |
|
||||
|
||||
## Общее для всех (решено и не обсуждается)
|
||||
|
||||
- Стиль — тёмный, из уже принятой темы клиента assistent: фон `#121218`,
|
||||
панели `#17212B`, акцент `#6AB2F2`, текст `#EBEBEB`.
|
||||
- Слева — список диалогов; выбран один; открывается конкретный чат.
|
||||
- Внизу — поле ввода, кнопка «Отправить» и отдельная кнопка записи микрофона.
|
||||
- Голос расшифровывается в текст **до** отправки — ошибку видно сразу.
|
||||
- Для агента важны прерывание ответа («Стоп») и показ хода работы.
|
||||
|
||||
## Что предстоит решить
|
||||
|
||||
1. **Откуда берётся распознавание речи.** В библиотеке `client` сейчас только текст
|
||||
и картинки — аудио в протоколе нет. Варианты: звать распознавание напрямую
|
||||
(у нас поднято на 88.125) либо сначала выяснить, как это делается на сервере агента.
|
||||
2. **Запись на удержание** (говоришь, пока держишь) или **по нажатию** (нажал —
|
||||
говоришь — нажал). В варианте 2 работает удержание пробела.
|
||||
3. **Нужны ли папки** для диалогов, как в мобильном клиенте, или хватит поиска.
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,25 @@
|
||||
# Вариант 2 — Rail / Voice-first
|
||||
|
||||
## Суть
|
||||
Голос — главное действие, а не дополнение к клавиатуре. Огромная кнопка микрофона
|
||||
слева в панели ввода, живая расшифровка на экране, пока говоришь.
|
||||
|
||||
## Ключевые решения
|
||||
- **Раскладка:** рейка иконок (76) + диалоги (296) + чат. Рейка — вход в память агента
|
||||
и инструменты, это не просто мессенджер.
|
||||
- **Голос:** кнопка 58 px, при записи краснеет и пульсирует. Над полем раскрывается
|
||||
панель: волна, таймер и **текст, который появляется по мере речи**.
|
||||
- **Три выхода из записи:** «Отправить» / «В поле ввода» (поправить руками) / «Отменить».
|
||||
Голосовое сообщение в ленте рисуется с волной и кнопкой проигрывания.
|
||||
- **Горячие клавиши:** Ctrl+R — запись, удержание пробела — говорить, Esc — отмена.
|
||||
|
||||
## Что хорошо
|
||||
- Голосом пользоваться реально удобно: видно, что тебя услышали, до отправки.
|
||||
- Ошибка распознавания не улетает агенту — сначала показывается текст.
|
||||
|
||||
## Что слабо
|
||||
- Больше элементов на экране, надо разбираться.
|
||||
- «Пробел на удержание» конфликтует с обычным набором текста — нужна аккуратность.
|
||||
|
||||
## Кому
|
||||
Тому, кто в основном диктует, а не печатает.
|
||||
@@ -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>
|
||||
@@ -0,0 +1,25 @@
|
||||
# Вариант 3 — Three-pane / Command center
|
||||
|
||||
## Суть
|
||||
Три панели: диалоги, чат и **состояние агента**. Работа агента видна не как текст
|
||||
в переписке, а как отдельные раскрывающиеся шаги.
|
||||
|
||||
## Ключевые решения
|
||||
- **Раскладка:** 268 + центр + 306. Третья панель — модель, контекст, инструменты,
|
||||
память агента, файлы сессии.
|
||||
- **Шаг агента** — сворачиваемый блок: что делал, сколько заняло, детали по клику.
|
||||
Это то, чем агент отличается от собеседника, и здесь оно вынесено в структуру.
|
||||
- **Кнопка записи:** компактная рядом с «Отправить»; при записи снизу появляется
|
||||
строка: квадрат «стоп», индикатор уровня, текущая расшифровка, таймер.
|
||||
- **Короткая диктовка** (от 2 с) уходит сразу, текст остаётся в поле.
|
||||
|
||||
## Что хорошо
|
||||
- Видно, что агент делает прямо сейчас и сколько это стоит.
|
||||
- Удобно на широком экране, ничего не надо прятать.
|
||||
|
||||
## Что слабо
|
||||
- На узком экране (ноутбук 1366) третью панель придётся убирать.
|
||||
- Настройки сессии — не то, на что хочется смотреть постоянно.
|
||||
|
||||
## Кому
|
||||
Тому, кто держит агента включённым весь день и следит за расходом.
|
||||
@@ -0,0 +1,345 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Вариант 3 — Three-pane / Command center</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0F1115; --panel: #161A21; --panel2: #1E242D; --panel3: #252C37; --line: #2A323D;
|
||||
--fg: #ECEFF3; --dim: #808FA0; --accent: #7C8CF8; --ok: #4ADE80; --warn: #FBBF24; --hot: #F87171;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background: #06080A; color: var(--fg); line-height: 1.45;
|
||||
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 22px;
|
||||
}
|
||||
.win { width: 1360px; height: 780px; background: var(--bg); border-radius: 13px; overflow: hidden;
|
||||
border: 1px solid var(--line); display: flex; flex-direction: column; box-shadow: 0 26px 90px rgba(0,0,0,.7); }
|
||||
|
||||
/* ---- строка меню-заголовка ---- */
|
||||
.titlebar { height: 42px; flex: 0 0 42px; display: flex; align-items: center; gap: 12px;
|
||||
padding: 0 16px; background: var(--panel); border-bottom: 1px solid var(--line); }
|
||||
.lights { display: flex; gap: 7px; }
|
||||
.lights i { width: 11px; height: 11px; border-radius: 50%; }
|
||||
.lights i:nth-child(1) { background: #FF5F57; } .lights i:nth-child(2) { background: #FEBC2E; }
|
||||
.lights i:nth-child(3) { background: #28C840; }
|
||||
.tb-title { font-size: 12.5px; color: var(--dim); }
|
||||
.tb-title b { color: var(--fg); font-weight: 550; }
|
||||
.tb-right { margin-left: auto; display: flex; align-items: center; gap: 14px; font-size: 11.5px; color: var(--dim); }
|
||||
.pill { background: var(--panel2); border: 1px solid var(--line); border-radius: 20px; padding: 3px 11px;
|
||||
display: flex; align-items: center; gap: 6px; }
|
||||
.pill .d { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
|
||||
|
||||
.body { flex: 1; display: flex; min-height: 0; }
|
||||
|
||||
/* ========== ПАНЕЛЬ 1: чаты ========== */
|
||||
.p1 { width: 268px; flex: 0 0 268px; background: var(--panel); border-right: 1px solid var(--line);
|
||||
display: flex; flex-direction: column; }
|
||||
.p-head { padding: 14px 15px 9px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.p-head h2 { font-size: 11px; letter-spacing: 1.3px; text-transform: uppercase; color: var(--dim); font-weight: 650; }
|
||||
.p-head button { background: var(--accent); border: none; color: #fff; border-radius: 7px;
|
||||
width: 24px; height: 24px; cursor: pointer; font-size: 15px; line-height: 1; }
|
||||
.f-tabs { display: flex; gap: 4px; padding: 0 12px 10px; flex-wrap: wrap; }
|
||||
.f-tab { font-size: 11px; padding: 4px 9px; border-radius: 7px; color: var(--dim); background: var(--panel2);
|
||||
cursor: pointer; border: 1px solid transparent; }
|
||||
.f-tab.on { color: var(--fg); border-color: var(--accent); background: rgba(124,140,248,.14); }
|
||||
.q { margin: 0 12px 9px; padding: 7px 11px; border-radius: 8px; border: 1px solid var(--line);
|
||||
background: var(--bg); color: var(--fg); font-size: 12.5px; outline: none; }
|
||||
.q:focus { border-color: var(--accent); } .q::placeholder { color: var(--dim); }
|
||||
.cf { flex: 1; overflow-y: auto; padding: 0 8px 12px; }
|
||||
.c { display: flex; gap: 10px; padding: 9px 10px; border-radius: 9px; cursor: pointer; }
|
||||
.c:hover { background: var(--panel2); }
|
||||
.c.on { background: var(--panel2); box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.c .a { width: 32px; height: 32px; flex: 0 0 32px; border-radius: 9px; display: grid; place-items: center;
|
||||
font-size: 11.5px; font-weight: 700; background: var(--panel3); color: var(--accent); }
|
||||
.c .m { flex: 1; min-width: 0; }
|
||||
.c .n { font-size: 13px; font-weight: 550; display: flex; justify-content: space-between; gap: 6px; }
|
||||
.c .n span.t { font-size: 10px; color: var(--dim); font-weight: 400; }
|
||||
.c .p { font-size: 11.5px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
|
||||
.c.new .n { color: var(--accent); }
|
||||
|
||||
/* ========== ПАНЕЛЬ 2: диалог ========== */
|
||||
.p2 { flex: 1; display: flex; flex-direction: column; min-width: 0; border-right: 1px solid var(--line); }
|
||||
.p2h { height: 54px; flex: 0 0 54px; display: flex; align-items: center; gap: 11px; padding: 0 18px;
|
||||
border-bottom: 1px solid var(--line); }
|
||||
.p2h .nm { font-size: 14px; font-weight: 600; }
|
||||
.p2h .sub { font-size: 11px; color: var(--dim); }
|
||||
.p2h .acts { margin-left: auto; display: flex; gap: 6px; }
|
||||
.p2h .acts button { background: var(--panel2); border: 1px solid var(--line); color: var(--dim);
|
||||
border-radius: 7px; padding: 5px 11px; font-size: 11.5px; cursor: pointer; }
|
||||
.p2h .acts button:hover { color: var(--fg); }
|
||||
|
||||
.flow { flex: 1; overflow-y: auto; padding: 18px 22px; display: flex; flex-direction: column; gap: 11px; }
|
||||
.u { align-self: flex-end; max-width: 78%; background: #2A3350; padding: 9px 13px; border-radius: 12px 12px 3px 12px;
|
||||
font-size: 13.5px; }
|
||||
.a { align-self: flex-start; max-width: 78%; background: var(--panel2); padding: 9px 13px;
|
||||
border-radius: 12px 12px 12px 3px; font-size: 13.5px; }
|
||||
.tm { font-size: 10px; color: var(--dim); margin-top: 4px; }
|
||||
|
||||
/* свёрнутый шаг работы агента — то, что отличает агента от чата */
|
||||
.step { align-self: flex-start; width: 100%; max-width: 92%; background: var(--panel);
|
||||
border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
||||
.step-h { display: flex; align-items: center; gap: 9px; padding: 8px 12px; cursor: pointer; font-size: 12.5px; }
|
||||
.step-h:hover { background: var(--panel2); }
|
||||
.step-h .ico { color: var(--accent); }
|
||||
.step-h .nm { color: var(--fg); font-weight: 550; }
|
||||
.step-h .stat { margin-left: auto; font-size: 10.5px; color: var(--ok); }
|
||||
.step-h .chev { color: var(--dim); font-size: 10px; }
|
||||
.step-b { display: none; padding: 0 12px 11px 34px; font-size: 12px; color: var(--dim); }
|
||||
.step.open .step-b { display: block; }
|
||||
.step-b code { color: #A8B6C8; background: var(--bg); border: 1px solid var(--line); border-radius: 5px;
|
||||
padding: 1px 6px; font-size: 11px; }
|
||||
|
||||
/* ========== ПАНЕЛЬ 3: состояние агента ========== */
|
||||
.p3 { width: 306px; flex: 0 0 306px; background: var(--panel); display: flex; flex-direction: column; }
|
||||
.sec { border-bottom: 1px solid var(--line); padding: 13px 15px; }
|
||||
.sec h3 { font-size: 10.5px; letter-spacing: 1.2px; text-transform: uppercase; color: var(--dim);
|
||||
font-weight: 650; margin-bottom: 10px; display: flex; align-items: center; gap: 7px; }
|
||||
.kv { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 7px; }
|
||||
.kv span:first-child { color: var(--dim); }
|
||||
.kv span:last-child { font-weight: 550; }
|
||||
.bar { height: 5px; border-radius: 3px; background: var(--panel3); overflow: hidden; margin-top: 4px; }
|
||||
.bar i { display: block; height: 100%; background: var(--accent); }
|
||||
.tool-chip { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 6px 0; }
|
||||
.tool-chip .s { width: 7px; height: 7px; border-radius: 50%; }
|
||||
.tool-chip .s.run { background: var(--warn); animation: p 1s infinite; }
|
||||
.tool-chip .s.ok { background: var(--ok); }
|
||||
@keyframes p { 50% { opacity: .3; } }
|
||||
.mem { font-size: 11.5px; color: var(--dim); padding: 7px 9px; background: var(--panel2);
|
||||
border-radius: 8px; margin-bottom: 6px; line-height: 1.4; }
|
||||
.mem b { color: var(--fg); font-weight: 550; display: block; font-size: 11.5px; margin-bottom: 2px; }
|
||||
|
||||
/* ========== КОМПОЗЕР (низ панели 2) ========== */
|
||||
.comp { border-top: 1px solid var(--line); background: var(--panel); padding: 13px 18px 14px; }
|
||||
.rec-state { display: none; align-items: center; gap: 12px; background: var(--bg); border: 1px solid var(--hot);
|
||||
border-radius: 10px; padding: 10px 13px; margin-bottom: 10px; }
|
||||
.rec-state.on { display: flex; }
|
||||
.stop-rec { width: 34px; height: 34px; flex: 0 0 34px; border-radius: 50%; background: var(--hot);
|
||||
border: none; color: #fff; cursor: pointer; display: grid; place-items: center; font-size: 12px; }
|
||||
.sq { width: 11px; height: 11px; background: #fff; border-radius: 2px; }
|
||||
.meter { flex: 1; display: flex; align-items: center; gap: 3px; height: 26px; }
|
||||
.meter i { width: 3px; background: var(--hot); border-radius: 2px; height: 25%; animation: mn .9s infinite ease-in-out; }
|
||||
.meter i:nth-child(2n) { animation-delay: .12s; } .meter i:nth-child(3n) { animation-delay: .24s; }
|
||||
@keyframes mn { 0%,100% { height: 20%; } 50% { height: 100%; } }
|
||||
.rt { font-variant-numeric: tabular-nums; font-size: 12.5px; color: var(--hot); font-weight: 600; }
|
||||
.rec-state .live-txt { font-size: 11.5px; color: var(--dim); max-width: 260px; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.crow { display: flex; align-items: flex-end; gap: 8px; }
|
||||
.cbox { flex: 1; display: flex; align-items: flex-end; background: var(--bg); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: 9px 12px; }
|
||||
.cbox:focus-within { border-color: var(--accent); }
|
||||
.cbox textarea { flex: 1; background: transparent; border: none; outline: none; color: var(--fg);
|
||||
font-family: inherit; font-size: 13.5px; resize: none; height: 21px; max-height: 110px; line-height: 1.45; }
|
||||
.cbox textarea::placeholder { color: var(--dim); }
|
||||
.ic { width: 36px; height: 36px; border-radius: 9px; border: 1px solid var(--line); background: var(--bg);
|
||||
color: var(--dim); cursor: pointer; display: grid; place-items: center; font-size: 15px; flex: 0 0 36px; }
|
||||
.ic:hover { color: var(--fg); }
|
||||
.ic.rec { border-color: var(--accent); color: var(--accent); }
|
||||
.ic.rec.live { background: var(--hot); border-color: var(--hot); color: #fff; animation: rp 1.4s infinite; }
|
||||
@keyframes rp { 0%,100% { box-shadow: 0 0 0 0 rgba(248,113,113,.5); } 50% { box-shadow: 0 0 0 9px rgba(248,113,113,0); } }
|
||||
.send { height: 36px; padding: 0 16px; border-radius: 9px; border: none; background: var(--accent);
|
||||
color: #fff; font-size: 12.5px; font-weight: 600; cursor: pointer; }
|
||||
.send:disabled { background: var(--panel2); color: var(--dim); cursor: default; }
|
||||
.keys { font-size: 10.5px; color: var(--dim); margin-top: 9px; display: flex; gap: 14px; }
|
||||
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="win">
|
||||
<div class="titlebar">
|
||||
<div class="lights"><i></i><i></i><i></i></div>
|
||||
<div class="tb-title">agentik-desktop — <b>agentik · рабочий</b></div>
|
||||
<div class="tb-right">
|
||||
<span class="pill"><span class="d"></span> агент на связи</span>
|
||||
<span>Память: 128 заметок</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<!-- ======== ПАНЕЛЬ 1 ======== -->
|
||||
<aside class="p1">
|
||||
<div class="p-head"><h2>Диалоги</h2><button title="Новый">+</button></div>
|
||||
<div class="f-tabs">
|
||||
<div class="f-tab on">Все</div><div class="f-tab">Работа</div>
|
||||
<div class="f-tab">Инфра</div><div class="f-tab">Дом</div><div class="f-tab">+</div>
|
||||
</div>
|
||||
<input class="q" placeholder="Поиск диалога…">
|
||||
<div class="cf">
|
||||
<div class="c on"><div class="a">АГ</div><div class="m">
|
||||
<div class="n"><span>agentik · рабочий</span><span class="t">14:22</span></div>
|
||||
<div class="p">Собрал отчёт, жду правок</div></div></div>
|
||||
<div class="c new"><div class="a">ОЧ</div><div class="m">
|
||||
<div class="n"><span>Отчёт за квартал</span><span class="t">12:05</span></div>
|
||||
<div class="p">Файл лежит на диске</div></div></div>
|
||||
<div class="c"><div class="a">РЗ</div><div class="m">
|
||||
<div class="n"><span>Разбор логов nginx</span><span class="t">вчера</span></div>
|
||||
<div class="p">Нашёл 3 ошибки 502</div></div></div>
|
||||
<div class="c"><div class="a">ПО</div><div class="m">
|
||||
<div class="n"><span>Solana-бэкенд</span><span class="t">вчера</span></div>
|
||||
<div class="p">Девайс-нет стартовал</div></div></div>
|
||||
<div class="c"><div class="a">КН</div><div class="m">
|
||||
<div class="n"><span>Книги</span><span class="t">пн</span></div>
|
||||
<div class="p">Разложил по полкам</div></div></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ======== ПАНЕЛЬ 2 ======== -->
|
||||
<section class="p2">
|
||||
<div class="p2h">
|
||||
<div>
|
||||
<div class="nm">agentik · рабочий</div>
|
||||
<div class="sub">codding-big · контекст 34%</div>
|
||||
</div>
|
||||
<div class="acts"><button>Стоп</button><button>Очистить</button></div>
|
||||
</div>
|
||||
|
||||
<div class="flow" id="flow">
|
||||
<div class="u">Разберись, почему CI красный, и выпусти релиз 3<span class="tm">14:05</span></div>
|
||||
|
||||
<div class="step open" onclick="this.classList.toggle('open')">
|
||||
<div class="step-h"><span class="ico">🔍</span><span class="nm">Поиск причины</span>
|
||||
<span class="stat">готово · 4 с</span><span class="chev">▼</span></div>
|
||||
<div class="step-b">Нашёл: в именах тестов длинное тире, сборка идёт в ASCII.<br>
|
||||
<code>InvalidPathException: unmappable characters</code></div>
|
||||
</div>
|
||||
|
||||
<div class="step" onclick="this.classList.toggle('open')">
|
||||
<div class="step-h"><span class="ico">✎</span><span class="nm">Правка 16 имён тестов</span>
|
||||
<span class="stat">готово · 12 с</span><span class="chev">▼</span></div>
|
||||
<div class="step-b">Заменено на обычный дефис в 4 файлах. Ничего другого не тронуто.</div>
|
||||
</div>
|
||||
|
||||
<div class="step" onclick="this.classList.toggle('open')">
|
||||
<div class="step-h"><span class="ico">⚙</span><span class="nm">Сборка и проверка</span>
|
||||
<span class="stat">готово · 1 м 10 с</span><span class="chev">▼</span></div>
|
||||
<div class="step-b">14 модулей, тесты зелёные, jar-ы собраны.</div>
|
||||
</div>
|
||||
|
||||
<div class="a">Готово. CI зелёный, релиз «3» опубликован: 101 артефакт в Nexus.<span class="tm">14:22</span></div>
|
||||
</div>
|
||||
|
||||
<div class="comp">
|
||||
<div class="rec-state" id="recState">
|
||||
<button class="stop-rec" onclick="stopRec(false)"><span class="sq"></span></button>
|
||||
<div class="meter" id="meter"></div>
|
||||
<span class="live-txt" id="liveTxt">слушаю…</span>
|
||||
<span class="rt" id="rt">0:00</span>
|
||||
<button class="ic" onclick="stopRec(true)" title="Отменить">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="crow">
|
||||
<div class="cbox"><textarea id="ta" placeholder="Сообщение агенту…"></textarea></div>
|
||||
<button class="ic rec" id="recBtn" title="Голосовое (Ctrl+R)">🎙</button>
|
||||
<button class="send" id="sendBtn" disabled>Отправить</button>
|
||||
</div>
|
||||
<div class="keys">
|
||||
<span><kbd>Ctrl</kbd>+<kbd>R</kbd> запись</span>
|
||||
<span><kbd>Enter</kbd> отправить</span>
|
||||
<span><kbd>Shift</kbd>+<kbd>Enter</kbd> перенос</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======== ПАНЕЛЬ 3 ======== -->
|
||||
<aside class="p3">
|
||||
<div class="sec">
|
||||
<h3>Сессия</h3>
|
||||
<div class="kv"><span>Модель</span><span>codding-big</span></div>
|
||||
<div class="kv"><span>Контекст</span><span>34 %</span></div>
|
||||
<div class="bar"><i style="width:34%"></i></div>
|
||||
<div class="kv" style="margin-top:9px"><span>Ходов</span><span>18</span></div>
|
||||
<div class="kv"><span>Стоимость</span><span>0.42 ₽</span></div>
|
||||
</div>
|
||||
|
||||
<div class="sec">
|
||||
<h3>Инструменты</h3>
|
||||
<div class="tool-chip"><span class="s ok"></span> shell · <span style="color:var(--dim)">готов</span></div>
|
||||
<div class="tool-chip"><span class="s ok"></span> read_file · <span style="color:var(--dim)">готов</span></div>
|
||||
<div class="tool-chip"><span class="s run"></span> write_file · <span style="color:var(--dim)">работает</span></div>
|
||||
<div class="tool-chip"><span class="s ok"></span> memory · <span style="color:var(--dim)">готов</span></div>
|
||||
</div>
|
||||
|
||||
<div class="sec">
|
||||
<h3>Память агента</h3>
|
||||
<div class="mem"><b>Про CI agentik</b>длинное тире в именах тестов ломает сборку в ASCII</div>
|
||||
<div class="mem"><b>Про Nexus</b>публикация идёт через общий action devops/publish</div>
|
||||
</div>
|
||||
|
||||
<div class="sec" style="border-bottom:none">
|
||||
<h3>Файлы сессии</h3>
|
||||
<div class="kv"><span>standalone-3.jar</span><span style="color:var(--dim)">240 МБ</span></div>
|
||||
<div class="kv"><span>agentik-cli-3.jar</span><span style="color:var(--dim)">7.2 МБ</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ta = document.getElementById('ta'), sendBtn = document.getElementById('sendBtn');
|
||||
const flow = document.getElementById('flow');
|
||||
const recBtn = document.getElementById('recBtn'), recState = document.getElementById('recState');
|
||||
const liveTxt = document.getElementById('liveTxt');
|
||||
document.getElementById('meter').innerHTML = Array.from({length: 34}, () => '<i></i>').join('');
|
||||
|
||||
ta.addEventListener('input', () => {
|
||||
sendBtn.disabled = ta.value.trim() === '';
|
||||
ta.style.height = '21px'; 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 = '21px'; sendBtn.disabled = true;
|
||||
setTimeout(() => {
|
||||
const s = document.createElement('div'); s.className = 'step';
|
||||
s.onclick = () => s.classList.toggle('open');
|
||||
s.innerHTML = '<div class="step-h"><span class="ico">⚙</span><span class="nm">Выполняю</span>' +
|
||||
'<span class="stat">идёт…</span><span class="chev">▼</span></div>' +
|
||||
'<div class="step-b">Задача: ' + t + '</div>';
|
||||
flow.appendChild(s);
|
||||
setTimeout(() => {
|
||||
s.querySelector('.stat').textContent = 'готово · 3 с';
|
||||
s.querySelector('.stat').style.color = 'var(--ok)';
|
||||
s.classList.add('open');
|
||||
add('Сделано: ' + t, 'a');
|
||||
}, 1400);
|
||||
}, 300);
|
||||
}
|
||||
function add(text, who) {
|
||||
const d = document.createElement('div'); d.className = who;
|
||||
const n = new Date();
|
||||
d.innerHTML = text + '<span class="tm">' + n.getHours() + ':' + String(n.getMinutes()).padStart(2,'0') + '</span>';
|
||||
flow.appendChild(d); flow.scrollTop = flow.scrollHeight;
|
||||
}
|
||||
sendBtn.onclick = send;
|
||||
ta.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } });
|
||||
|
||||
const P = 'разберись с красным си ай и выпусти новый релиз'.split(' ');
|
||||
let tm = null, sec = 0, wi = 0;
|
||||
function startRec() {
|
||||
sec = 0; wi = 0; recState.classList.add('on'); recBtn.classList.add('live');
|
||||
tm = setInterval(() => {
|
||||
sec++; document.getElementById('rt').textContent = '0:' + String(sec).padStart(2,'0');
|
||||
if (wi < P.length) liveTxt.textContent = P.slice(0, ++wi).join(' ');
|
||||
}, 500);
|
||||
}
|
||||
function stopRec(cancel) {
|
||||
clearInterval(tm); recState.classList.remove('on'); recBtn.classList.remove('live');
|
||||
if (cancel) return;
|
||||
const t = liveTxt.textContent;
|
||||
if (t && sec >= 2) { add(t, 'u'); setTimeout(() => add('Принято голосом, начинаю.', 'a'), 800); }
|
||||
}
|
||||
recBtn.onclick = () => recState.classList.contains('on') ? stopRec(false) : startRec();
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'r') { e.preventDefault(); recBtn.click(); }
|
||||
if (e.key === 'Escape' && recState.classList.contains('on')) stopRec(true);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user