Compare commits

...
Author SHA1 Message Date
m 54de3f44d0 chore: verify and finalize human-facing chat surface
CI / test (push) Successful in 19s
2026-08-18 12:42:51 +03:00
3 changed files with 977 additions and 222 deletions
+477 -65
View File
@@ -1,76 +1,488 @@
:root {
--ink: #e9eee7;
--muted: #93a096;
--base: #0b100e;
--panel: #101714;
--line: #2a352f;
--signal: #c9a85f;
--bad: #de816f;
--good: #78c6a3;
--radius: 3px;
--ink: #e7efe9;
--muted: #96a49d;
--faint: #5f6e66;
--surface: #0f1512;
--surface-alt: #151d18;
--line: #27322c;
--base: #090d0b;
--good: #7cc7a4;
--signal: #ceb27a;
--bad: #d88d78;
--radius-lg: 16px;
--radius: 10px;
--sans: "Bahnschrift", "Aptos Narrow", "Segoe UI", sans-serif;
--serif: Georgia, "Times New Roman", serif;
--mono: "Cascadia Mono", Consolas, monospace;
--shadow: 0 24px 56px rgba(0, 0, 0, .22);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; min-width: 320px; min-height: 100vh; color: var(--ink); background: var(--base); font-family: var(--sans); }
body {
margin: 0;
min-height: 100vh;
min-width: 320px;
color: var(--ink);
background:
radial-gradient(circle at 10% 0%, rgba(120, 182, 160, .16), transparent 40%),
radial-gradient(circle at 90% 0%, rgba(201, 168, 95, .16), transparent 35%),
var(--base);
font-family: var(--sans);
}
button, input, textarea { font: inherit; }
button { cursor: pointer; color: inherit; border-radius: var(--radius); }
.chat-shell { min-height: 100vh; display: grid; }
.login-panel { width: min(100% - 32px, 420px); margin: auto; padding: 46px 40px; border: 1px solid var(--line); background: var(--panel); }
.brand-mark { width: 46px; height: 46px; display: grid; place-items: center; color: var(--base); background: var(--signal); font: 700 24px/1 var(--serif); clip-path: polygon(50% 0, 92% 22%, 92% 76%, 50% 100%, 8% 76%, 8% 22%); }
h1 { margin: 30px 0; font: 400 58px/.95 var(--serif); letter-spacing: -.02em; }
label { display: block; margin: 16px 0 8px; color: var(--muted); font: 600 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
input, textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; }
input { height: 48px; padding: 0 12px; }
textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px; line-height: 1.45; }
.login-form button, .composer button { border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
.login-form button { width: 100%; height: 50px; margin-top: 18px; }
#login-error { min-height: 20px; color: var(--bad); font-size: 13px; }
.chat-view { min-height: 100vh; display: grid; grid-template-rows: 64px minmax(0, 1fr); }
.chat-header { display: flex; align-items: center; gap: 18px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .96); }
.chat-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); }
.chat-header span { color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
#active-focus { margin-left: auto; color: var(--good); }
.chat-header button { width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; }
.human-nav { display: flex; align-items: center; gap: 8px; }
.nav-tab, .operator-link { height: 34px; padding: 0 12px; display: inline-flex; align-items: center; border: 1px solid var(--line); color: var(--muted); background: #0b110e; text-decoration: none; font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
.nav-tab.active { color: #11150f; border-color: var(--signal); background: var(--signal); }
.operator-link { color: var(--good); }
.surface-panel { min-height: 0; position: relative; }
.active-panel { display: grid; min-height: 0; }
.messages { width: min(100%, 920px); margin: 0 auto; padding: 28px 18px 120px; display: flex; flex-direction: column; gap: 14px; overflow: auto; }
.message { max-width: min(720px, 92%); padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); line-height: 1.45; white-space: pre-wrap; }
.message.user { align-self: flex-end; background: #151e1a; }
.message.assistant { align-self: flex-start; background: #0f1613; }
.message small { display: block; margin-top: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; }
.composer { position: sticky; bottom: 0; width: min(100%, 920px); margin: 0 auto; padding: 16px 18px 20px; display: grid; grid-template-columns: minmax(0, 1fr) 130px; gap: 12px; background: linear-gradient(transparent, var(--base) 20%); }
.composer button { height: 48px; align-self: end; }
.composer button:disabled { cursor: wait; filter: saturate(.3); }
.attention-surface, .history-surface { width: min(100%, 920px); margin: 0 auto; padding: 34px 18px; align-content: start; gap: 18px; }
.surface-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 16px; }
.surface-heading p { margin: 0; font: 400 36px/.95 var(--serif); }
.surface-heading span, .attention-card small, .history-item span { color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .1em; }
.surface-heading button { height: 36px; padding: 0 12px; border: 1px solid var(--line); background: transparent; }
.attention-list, .history-list { display: grid; gap: 12px; }
.attention-card, .history-item { border: 1px solid var(--line); background: #0f1613; padding: 16px; }
.attention-card time { color: var(--signal); font: 12px/1 var(--mono); }
.attention-card h2 { margin: 10px 0 8px; font: 400 24px/1.1 var(--serif); }
.attention-card p, .history-item p { margin: 0 0 10px; color: var(--ink); line-height: 1.45; }
.attention-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.attention-actions button { height: 34px; padding: 0 12px; border: 1px solid var(--line); background: #151e1a; color: var(--ink); font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
.attention-actions button:first-child { border-color: var(--good); color: var(--good); }
.history-item.user { border-color: #34443c; background: #151e1a; }
.history-item strong { display: block; margin-bottom: 8px; color: var(--signal); }
.empty-state { margin: 24px 0 0; color: var(--muted); }
button { color: inherit; border: 0; border-radius: var(--radius); }
p { margin: 0; }
.assistant-shell,
.chat-view { min-height: 100vh; }
.auth-view {
min-height: 100vh;
display: grid;
place-items: center;
padding: 36px 20px;
}
.auth-card {
width: min(100%, 440px);
padding: 30px;
border-radius: var(--radius-lg);
border: 1px solid var(--line);
background: rgba(15, 21, 18, .92);
box-shadow: var(--shadow);
}
.brand-block,
.header-brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-mark {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-radius: 50%;
color: var(--base);
background: linear-gradient(145deg, var(--good), #7aa7bd);
font: 700 24px/1 var(--serif);
box-shadow: 0 0 0 4px rgba(124, 199, 164, .12);
}
h1 {
margin: 0;
font: 500 42px/1.05 var(--serif);
}
.brand-kicker {
margin: 0 0 4px;
color: var(--faint);
text-transform: uppercase;
letter-spacing: .1em;
font-size: 11px;
font-family: var(--mono);
}
.auth-copy {
margin: 18px 0 22px;
color: var(--muted);
line-height: 1.45;
}
label {
display: block;
margin: 0 0 6px;
color: var(--faint);
font-size: 12px;
letter-spacing: .03em;
}
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink);
background: #080e0c;
}
input { height: 46px; padding: 0 14px; }
input:focus,
textarea:focus,
.primary-button:focus,
.ghost-button:focus,
.nav-tab:focus-visible,
button:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
textarea {
min-height: 96px;
max-height: 220px;
padding: 14px;
line-height: 1.45;
resize: vertical;
}
.input-with-action {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.icon-button,
.ghost-button,
.primary-button {
height: 44px;
border-radius: var(--radius);
}
.icon-button,
.ghost-button {
background: rgba(255, 255, 255, .05);
border: 1px solid var(--line);
color: var(--muted);
}
.primary-button {
background: var(--signal);
color: #111810;
font-weight: 700;
}
#login-button,
.primary-button { width: 100%; }
#login-button,
#send { margin-top: 14px; }
.login-form,
.composer {
display: grid;
gap: 12px;
}
#login-error {
min-height: 20px;
color: var(--bad);
}
.chat-view {
width: min(100%, 980px);
margin: 0 auto;
padding: 14px 16px 24px;
display: grid;
gap: 12px;
}
.assistant-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 4px;
}
.assistant-header h1 {
font-size: clamp(30px, 4.4vw, 38px);
letter-spacing: -.02em;
}
.session-state {
display: flex;
align-items: center;
gap: 10px;
}
.status-pill {
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
background: #0f1512;
font-size: 12px;
}
.human-nav {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px;
width: fit-content;
background: #0c1210;
}
.nav-tab {
height: 34px;
padding: 0 14px;
border-radius: 999px;
background: transparent;
color: var(--muted);
}
.nav-tab.active,
.nav-tab:hover {
background: var(--signal);
color: #11150f;
}
.surface-panel {
display: grid;
align-content: start;
min-height: 0;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(180deg, rgba(21, 29, 24, .86), rgba(15, 21, 18, .95));
box-shadow: var(--shadow);
overflow: hidden;
}
.surface-panel.active-panel {
display: grid;
}
.panel-toolbar {
padding: 16px 18px;
border-bottom: 1px solid var(--line);
background: #0c1210;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.panel-toolbar h2 {
margin: 0;
font: 500 26px/1.12 var(--serif);
}
.panel-toolbar p {
margin-top: 6px;
color: var(--muted);
line-height: 1.45;
}
.focus-chip {
margin: 8px 0 0;
font-size: 12px;
color: var(--good);
padding: 6px 10px;
border: 1px solid rgba(124, 199, 164, .45);
border-radius: 999px;
background: rgba(124, 199, 164, .12);
}
.messages {
padding: 24px 18px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: auto;
min-height: calc(100vh - 280px);
max-height: calc(100vh - 280px);
}
.message {
width: min(92%, 760px);
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px 14px;
line-height: 1.45;
white-space: pre-wrap;
background: #0f1512;
}
.message.assistant { align-self: flex-start; background: #121a16; }
.message.user { align-self: flex-end; background: #19231c; border-color: rgba(120, 191, 162, .4); }
.message-author { color: var(--faint); font-size: 11px; margin-bottom: 6px; font-weight: 700; }
.message.user .message-author { color: #b6ddb8; }
.message-time { margin-top: 8px; font-size: 11px; color: var(--faint); font-family: var(--mono); }
.message small { display: block; margin-top: 6px; color: var(--faint); }
.composer {
position: sticky;
bottom: 0;
padding: 14px 16px 16px;
border-top: 1px solid var(--line);
background: linear-gradient(transparent, rgba(11, 16, 14, .88));
grid-template-columns: minmax(0, 1fr) auto;
}
.composer-actions { display: grid; align-items: end; }
.composer button:disabled {
cursor: wait;
opacity: .7;
}
.surface-panel:not(.active-panel) { display: none; }
.attention-surface,
.history-surface { padding: 0 0 12px; }
.attention-list,
.history-list {
display: grid;
gap: 12px;
padding: 18px;
}
.attention-card,
.history-item {
border: 1px solid var(--line);
border-radius: 12px;
background: #0b110f;
padding: 14px;
}
.attention-card h3,
.history-item strong { margin: 0 0 8px; font: 600 15px/1.2 var(--serif); }
.attention-card time,
.history-item time,
.attention-card .muted,
.history-item .muted {
color: var(--muted);
font-size: 12px;
font-family: var(--mono);
}
.attention-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.attention-actions .ghost-button,
.action-row button {
height: 34px;
padding: 0 11px;
background: rgba(255, 255, 255, .06);
color: var(--ink);
}
.attention-actions .ghost-button {
border: 1px solid rgba(194, 168, 118, .48);
color: #f2e8d0;
}
.attention-actions .ghost-button.primary { border-color: transparent; background: var(--good); color: #11211b; }
.history-item {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.history-item p {
margin: 0 0 8px;
color: var(--ink);
white-space: pre-wrap;
}
.history-item .history-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
color: var(--faint);
font-family: var(--mono);
font-size: 11px;
}
.history-item .message-kind {
text-transform: uppercase;
letter-spacing: .08em;
color: var(--signal);
}
.status-bar {
margin: 8px auto 0;
max-width: 980px;
color: var(--faint);
padding: 10px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(15, 21, 18, .8);
min-height: 18px;
visibility: hidden;
}
.status-bar.visible { visibility: visible; }
.composer-error {
position: fixed;
right: 16px;
bottom: 16px;
max-width: min(420px, calc(100vw - 32px));
border-radius: 12px;
border: 1px solid rgba(216, 141, 120, .36);
background: #241715;
color: #ffd8ca;
padding: 10px 12px;
transform: translateY(16px);
opacity: 0;
transition: .2s ease;
}
.composer-error.show {
transform: translateY(0);
opacity: 1;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
border: 0;
white-space: nowrap;
}
.empty-state {
color: var(--muted);
padding: 18px;
}
.empty-state.error { color: var(--bad); }
@media (max-width: 640px) {
.composer { grid-template-columns: 1fr; }
.composer button { width: 100%; }
.chat-header { gap: 10px; padding: 0 12px; }
.chat-header span { display: none; }
.human-nav { overflow-x: auto; }
.operator-link, .nav-tab { padding: 0 9px; }
@media (max-width: 720px) {
.chat-view {
padding: 10px 10px 18px;
gap: 10px;
}
.assistant-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.session-state { width: 100%; justify-content: space-between; }
.messages {
min-height: calc(100vh - 320px);
max-height: calc(100vh - 320px);
}
.composer {
grid-template-columns: 1fr;
}
.panel-toolbar {
flex-direction: column;
}
.attention-list,
.history-list {
padding: 12px;
}
}
+424 -122
View File
@@ -2,227 +2,518 @@ const loginView = document.querySelector("#login-view");
const chatView = document.querySelector("#chat-view");
const loginForm = document.querySelector("#login-form");
const loginError = document.querySelector("#login-error");
const togglePassword = document.querySelector("#toggle-password");
const usernameInput = document.querySelector("#username");
const passwordInput = document.querySelector("#password");
const messages = document.querySelector("#messages");
const chatForm = document.querySelector("#chat-form");
const messageInput = document.querySelector("#message");
const sendButton = document.querySelector("#send");
const logoutButton = document.querySelector("#logout");
const clearHistoryButton = document.querySelector("#clear-history");
const activeFocus = document.querySelector("#active-focus");
const todayList = document.querySelector("#today-list");
const todaySubtitle = document.querySelector("#today-subtitle");
const refreshToday = document.querySelector("#refresh-today");
const historyList = document.querySelector("#history-list");
const navTabs = Array.from(document.querySelectorAll(".nav-tab"));
const statusPill = document.querySelector("#connection-state");
const statusBar = document.querySelector("#status-bar");
const composerError = document.querySelector("#composer-error");
let token = sessionStorage.getItem("domovoy.chat.token") || "";
let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || "";
const SESSION_KEY = "domovoy.chat.token";
const CONVERSATION_KEY = "domovoy.chat.conversation";
const SECTION_KEY = "domovoy.chat.section";
let token = sessionStorage.getItem(SESSION_KEY) || "";
let conversationID = sessionStorage.getItem(CONVERSATION_KEY) || "";
let localHistory = [];
let todayRequestInFlight = false;
function setConnectionState(text, tone = "neutral") {
if (!statusPill) return;
statusPill.textContent = text;
if (tone === "bad") {
statusPill.style.borderColor = "rgba(216, 141, 120, .6)";
statusPill.style.color = "var(--bad)";
} else {
statusPill.style.borderColor = "var(--line)";
statusPill.style.color = "var(--muted)";
}
}
function showToast(message) {
if (!statusBar) return;
statusBar.textContent = message;
statusBar.classList.add("visible");
clearTimeout(showToast._timer);
showToast._timer = setTimeout(() => {
statusBar.classList.remove("visible");
}, 2800);
}
function showComposerError(message) {
if (!composerError) return;
composerError.textContent = message;
composerError.classList.add("show");
clearTimeout(showComposerError._timer);
showComposerError._timer = setTimeout(() => {
composerError.classList.remove("show");
}, 2600);
}
function formatTime(value = new Date()) {
return new Intl.DateTimeFormat("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(value);
}
function showChat() {
setConnectionState("Сессия активна");
loginView.hidden = true;
chatView.hidden = false;
if (!messages.children.length) {
appendMessage("assistant", "Я рядом. Напишите обычное сообщение, без внутренних идентификаторов.");
appendMessage("assistant", "Я рядом. Пишите просто и по-человечески, я всё пойму.");
}
const storedSection = localStorage.getItem(SECTION_KEY);
activateTab(storedSection === "today-panel" || storedSection === "history-panel" ? storedSection : "chat-panel");
messageInput.focus();
}
function showLogin() {
setConnectionState("Вход не выполнен", "bad");
chatView.hidden = true;
loginView.hidden = false;
usernameInput?.focus();
}
function appendMessage(kind, text, meta = "") {
const node = document.createElement("div");
node.className = `message ${kind}`;
node.textContent = text;
if (meta) {
const small = document.createElement("small");
small.textContent = meta;
node.appendChild(small);
}
messages.appendChild(node);
messages.scrollTop = messages.scrollHeight;
localHistory.push({kind, text, meta, at: new Date()});
renderHistory();
}
function updateActiveFocus(value, provenance = "") {
if (!activeFocus) return;
activeFocus.textContent = value ? `Сейчас обсуждаем: ${value}` : "";
activeFocus.hidden = !value;
function endSession(message = "Сессия завершена") {
token = "";
conversationID = "";
sessionStorage.removeItem(SESSION_KEY);
sessionStorage.removeItem(CONVERSATION_KEY);
messages.replaceChildren();
localHistory = [];
updateActiveFocus("");
setConnectionState("Вход не выполнен", "bad");
showToast(message);
showLogin();
}
async function api(path, options = {}) {
const headers = {"Content-Type": "application/json", ...(options.headers || {})};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(path, {...options, headers});
if (!response.ok) throw new Error(await response.text());
const headers = new Headers(options.headers || {});
if (token) headers.set("Authorization", `Bearer ${token}`);
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(path, { ...options, headers });
if (response.status === 401 && path !== "/api/session") {
endSession("Сессия просрочена, войдите снова");
throw new Error("Требуется авторизация");
}
if (!response.ok) {
const detail = (await response.text()).trim();
throw new Error(detail || `Ошибка ${response.status}`);
}
return response.json();
}
function showError(message) {
showComposerError(message || "Произошла ошибка");
}
function appendMessage(kind, text, meta = "") {
const node = document.createElement("article");
node.className = `message ${kind}`;
const who = document.createElement("strong");
who.className = "message-author";
who.textContent = kind === "user" ? "Ты" : "Домовой";
const body = document.createElement("p");
body.textContent = text;
node.append(who, body);
if (meta) {
const metaNode = document.createElement("small");
metaNode.textContent = meta;
node.appendChild(metaNode);
}
const when = document.createElement("time");
when.className = "message-time";
when.textContent = formatTime();
node.appendChild(when);
messages.appendChild(node);
messages.scrollTop = messages.scrollHeight;
localHistory.push({
kind,
text,
meta,
at: new Date(),
});
if (localHistory.length > 120) {
localHistory = localHistory.slice(-120);
}
renderHistory();
}
function updateActiveFocus(value) {
if (!activeFocus) return;
if (value) {
activeFocus.textContent = `Сейчас обсуждаем: ${value}`;
activeFocus.hidden = false;
} else {
activeFocus.hidden = true;
activeFocus.textContent = "";
}
}
function humanMeta(result) {
if (result.reminder_scheduled) return "Напоминание сохранено. Я вернусь к нему сам.";
if (result.delegation_id && result.delegation_state) return `Передал в coding-agent: ${humanDelegationState(result.delegation_state)}.`;
if (result.capability_status && result.capability_status !== "AVAILABLE") return "Возможность пока не подключена.";
if (result.work_planned) return "Я разложил это как работу.";
if (result.reminder_scheduled) {
return "Я зафиксировал напоминание и сообщу, когда наступит время.";
}
if (result.delegation_id && result.delegation_state) {
return `Передал дальше: ${humanDelegationState(result.delegation_state)}.`;
}
if (result.capability_status && result.capability_status !== "AVAILABLE") {
return "Эта задача сейчас проверяется отдельно.";
}
if (result.work_planned) {
return "Я взял задачу в работу и отслежу статус.";
}
return "";
}
function humanDelegationState(state) {
switch (state) {
case "RUNNING":
return "работа запущена";
return "выполняется";
case "WAITING":
return "ожидаю следующий шаг";
return "ожидает следующего шага";
case "BLOCKED":
return "нужно решение человека";
return "нужен человеческий выбор";
case "VERIFYING":
return "результат проверяется";
return "результат на проверке";
case "COMPLETED":
return "работа завершена";
return "готово";
case "FAILED":
return "run завершился ошибкой";
return "завершилось ошибкой";
default:
return state.toLowerCase();
return String(state || "обрабатывается").toLowerCase();
}
}
function renderHistory() {
if (!historyList) return;
historyList.replaceChildren();
const items = localHistory.slice(-30).reverse();
const items = localHistory.slice(-50).slice().reverse();
if (!items.length) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = "История появится после сообщений в этом окне.";
empty.textContent = "История появится после первых сообщений в этом окне.";
historyList.appendChild(empty);
return;
}
for (const item of items) {
items.forEach((item) => {
const row = document.createElement("article");
row.className = `history-item ${item.kind}`;
const title = document.createElement("strong");
title.textContent = item.kind === "user" ? "Вы" : "Домовой";
row.className = "history-item";
const who = document.createElement("strong");
who.className = "message-kind";
who.textContent = item.kind === "user" ? "Ты" : "Домовой";
const body = document.createElement("p");
body.textContent = item.text;
const time = document.createElement("span");
time.textContent = item.at.toLocaleTimeString("ru-RU", {hour: "2-digit", minute: "2-digit"});
row.append(title, body, time);
const footer = document.createElement("div");
footer.className = "history-meta";
const time = document.createElement("time");
time.textContent = formatTime(item.at);
const meta = document.createElement("span");
meta.textContent = item.meta || "";
footer.append(time, meta);
row.append(who, body, footer);
historyList.appendChild(row);
});
}
function createTodayCard(item) {
const card = document.createElement("article");
card.className = "attention-card";
const title = document.createElement("h3");
const when = document.createElement("time");
const state = document.createElement("p");
const source = document.createElement("p");
const hint = document.createElement("p");
const actions = document.createElement("div");
const stamp = item.WhenUnixMillis || item.when_unix_millis || 0;
when.textContent = stamp
? new Date(stamp).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "текущий момент";
when.className = "muted";
title.textContent = item.Title || item.title || "Событие";
state.textContent = item.HumanState || item.human_state || "ожидает внимания";
state.className = "muted";
const sourceText = [
item.SourceDescription || item.source_description,
item.DeliveryState || item.delivery_state,
]
.filter(Boolean)
.join(" · ");
source.textContent = sourceText || "Источник не задан";
source.className = "muted";
const hintText = item.ResponseHint || item.response_hint || "";
if (hintText) {
hint.textContent = hintText;
}
actions.className = "attention-actions";
const quick = [
{ label: "Готово", text: "Готово", tone: "primary" },
{ label: "Позже", text: "Отложи на 10 минут" },
{ label: "Отменить", text: "Отмени" },
];
quick.forEach((action) => {
const actionButton = document.createElement("button");
actionButton.type = "button";
actionButton.className = `ghost-button ${action.tone || ""}`.trim();
actionButton.textContent = action.label;
actionButton.addEventListener("click", async () => {
const previous = actionButton.textContent;
actionButton.disabled = true;
actionButton.textContent = "Отправляю…";
try {
await sendChatMessage(action.text, { silentUser: false });
await loadToday();
activateTab("chat-panel");
} finally {
actionButton.disabled = false;
actionButton.textContent = previous;
}
});
actions.appendChild(actionButton);
});
card.append(title, when, state, source);
if (hintText) card.append(hint);
card.append(actions);
return card;
}
function renderTodayEmpty(message) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = message;
todayList.appendChild(empty);
}
async function loadToday() {
if (!todayList || !token) return;
if (!todayList || todayRequestInFlight) return;
todayRequestInFlight = true;
todayList.replaceChildren();
const loading = document.createElement("p");
loading.className = "empty-state";
loading.textContent = "Смотрю, что требует внимания...";
todayList.appendChild(loading);
renderTodayEmpty("Собираю актуальные напоминания…");
try {
const result = await api("/api/today", {method: "GET"});
todayList.replaceChildren();
const result = await api("/api/today", { method: "GET" });
const items = result.Items || result.items || [];
todayList.replaceChildren();
if (!items.length) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = "Сейчас ничего срочного не требует внимания.";
todayList.appendChild(empty);
todaySubtitle.textContent = "Сейчас спокойный период. Нечего тревожного";
renderTodayEmpty("Сейчас ничего срочного не требует внимания.");
return;
}
todaySubtitle.textContent = `Найдено записей: ${items.length}`;
for (const item of items) {
const card = document.createElement("article");
card.className = "attention-card";
const when = document.createElement("time");
const stamp = item.WhenUnixMillis || item.when_unix_millis || 0;
when.textContent = stamp ? new Date(stamp).toLocaleString("ru-RU", {day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"}) : "сейчас";
const title = document.createElement("h2");
title.textContent = item.Title || item.title || "Событие Домового";
const state = document.createElement("p");
state.textContent = item.HumanState || item.human_state || "ожидает внимания";
const source = document.createElement("small");
source.textContent = [item.SourceDescription || item.source_description, item.DeliveryState || item.delivery_state].filter(Boolean).join(" · ");
const hintText = item.ResponseHint || item.response_hint || "";
const actions = document.createElement("div");
actions.className = "attention-actions";
for (const [label, text] of [["Готово", "Готово"], ["Отложить", "Отложи на 10 минут"], ["Отменить", "Отмени"]]) {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.addEventListener("click", async () => {
await sendChatMessage(text, {silentUser: false});
await loadToday();
activateTab("chat-panel");
});
actions.appendChild(button);
}
if (hintText) {
const hint = document.createElement("small");
hint.textContent = hintText;
card.append(when, title, state, source, hint, actions);
} else {
card.append(when, title, state, source, actions);
}
todayList.appendChild(card);
todayList.appendChild(createTodayCard(item));
}
} catch {
} catch (error) {
todayList.replaceChildren();
const error = document.createElement("p");
error.className = "empty-state error";
error.textContent = "Внимание сейчас недоступно. Чат можно продолжать.";
todayList.appendChild(error);
const err = document.createElement("p");
err.className = "empty-state error";
err.textContent = error.message || "Сегодня недоступен на мгновение.";
todayList.appendChild(err);
todaySubtitle.textContent = "Не удалось обновить список.";
} finally {
todayRequestInFlight = false;
}
}
async function sendChatMessage(text, options = {}) {
const clean = text.trim();
if (!clean) return;
if (!options.silentUser) appendMessage("user", clean);
if (!options.silentUser) {
appendMessage("user", clean);
}
sendButton.disabled = true;
messageInput.disabled = true;
const originalLabel = sendButton.textContent;
sendButton.textContent = "Отправляю…";
try {
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean})});
const result = await api("/api/chat/messages", {
method: "POST",
body: JSON.stringify({
conversation_id: conversationID,
message: clean,
}),
});
conversationID = result.conversation_id;
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
updateActiveFocus(result.active_focus, result.project_provenance);
appendMessage("assistant", result.reply_text || "Сообщение записано.", humanMeta(result));
if (conversationID) {
sessionStorage.setItem(CONVERSATION_KEY, conversationID);
}
updateActiveFocus(result.active_focus);
appendMessage("assistant", result.reply_text || "Принял тебя. Подготовлю ответ.", humanMeta(result));
if (/что сейчас требует моего внимания/i.test(clean)) {
await loadToday();
activateTab("today-panel");
}
} catch {
appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime.");
showToast("Сообщение отправлено");
} catch (error) {
appendMessage("assistant", "Я не могу отправить сообщение в этот момент. Проверьте сеть и авторизацию.");
showError(error.message);
} finally {
sendButton.disabled = false;
messageInput.focus();
sendButton.textContent = originalLabel;
messageInput.disabled = false;
if (!options.silentUser) {
messageInput.focus();
}
}
}
function activateTab(panelID) {
for (const tab of navTabs) tab.classList.toggle("active", tab.dataset.tab === panelID);
for (const panel of document.querySelectorAll(".surface-panel")) {
let activeSet = false;
navTabs.forEach((tab) => {
const isActive = tab.dataset.tab === panelID;
tab.classList.toggle("active", isActive);
tab.setAttribute("aria-selected", String(isActive));
tab.tabIndex = isActive ? 0 : -1;
if (isActive) activeSet = true;
});
document.querySelectorAll(".surface-panel").forEach((panel) => {
const active = panel.id === panelID;
panel.hidden = !active;
panel.classList.toggle("active-panel", active);
});
if (!activeSet) return;
localStorage.setItem(SECTION_KEY, panelID);
if (panelID === "today-panel") {
loadToday();
}
if (panelID === "history-panel") {
renderHistory();
}
}
function navigateTabs(event) {
const current = navTabs.findIndex((tab) => tab.classList.contains("active"));
if (current === -1) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
const index = (current - 1 + navTabs.length) % navTabs.length;
activateTab(navTabs[index].dataset.tab);
navTabs[index].focus();
}
if (event.key === "ArrowRight") {
event.preventDefault();
const index = (current + 1) % navTabs.length;
activateTab(navTabs[index].dataset.tab);
navTabs[index].focus();
}
if (panelID === "today-panel") loadToday();
if (panelID === "history-panel") renderHistory();
}
loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
const username = usernameInput.value.trim();
const password = passwordInput.value;
const loginButton = document.querySelector("#login-button");
loginError.textContent = "";
const username = document.querySelector("#username").value;
const password = document.querySelector("#password").value;
loginButton.disabled = true;
loginButton.textContent = "Проверяю…";
try {
const result = await api("/api/session", {method: "POST", body: JSON.stringify({username, password})});
token = result.Token || result.token;
sessionStorage.setItem("domovoy.chat.token", token);
const result = await api("/api/session", {
method: "POST",
body: JSON.stringify({ username, password }),
});
token = result.Token || result.token || "";
if (!token) {
throw new Error("Пустой токен");
}
sessionStorage.setItem(SESSION_KEY, token);
conversationID = "";
sessionStorage.removeItem(CONVERSATION_KEY);
showChat();
} catch {
loginError.textContent = "Вход не выполнен";
loginError.textContent = "Не удалось войти. Проверьте логин и пароль.";
showToast("Не удалось войти");
} finally {
loginButton.disabled = false;
loginButton.textContent = "Открыть чат";
}
});
togglePassword?.addEventListener("click", () => {
const isPassword = passwordInput.type === "password";
passwordInput.type = isPassword ? "text" : "password";
togglePassword.textContent = isPassword ? "скрыть" : "показать";
togglePassword.setAttribute("aria-label", isPassword ? "Скрыть пароль" : "Показать пароль");
});
chatForm.addEventListener("submit", async (event) => {
event.preventDefault();
const text = messageInput.value.trim();
@@ -231,20 +522,31 @@ chatForm.addEventListener("submit", async (event) => {
await sendChatMessage(text);
});
messageInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
chatForm.requestSubmit();
}
});
for (const tab of navTabs) {
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
tab.addEventListener("click", () => {
activateTab(tab.dataset.tab);
});
tab.addEventListener("keydown", navigateTabs);
}
if (refreshToday) refreshToday.addEventListener("click", loadToday);
refreshToday?.addEventListener("click", loadToday);
logoutButton.addEventListener("click", () => {
token = "";
conversationID = "";
sessionStorage.removeItem("domovoy.chat.token");
sessionStorage.removeItem("domovoy.chat.conversation");
clearHistoryButton?.addEventListener("click", () => {
localHistory = [];
messages.replaceChildren();
updateActiveFocus("");
showLogin();
renderHistory();
showToast("История очищена");
});
logoutButton?.addEventListener("click", () => {
endSession("Вы вышли из сеанса");
});
if (token) {
+76 -35
View File
@@ -5,60 +5,101 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#101714">
<title>Домовой - чат</title>
<title>Домовой — твой помощник</title>
<link rel="stylesheet" href="/assets/chat.css">
</head>
<body>
<main class="chat-shell">
<section id="login-view" class="login-panel">
<div class="brand-mark" aria-hidden="true">Д</div>
<h1>Домовой</h1>
<form id="login-form" class="login-form">
<label for="username">Логин</label>
<input id="username" autocomplete="username" required autofocus>
<label for="password">Пароль</label>
<input id="password" type="password" autocomplete="current-password" required>
<button type="submit">Войти</button>
<p id="login-error" role="alert"></p>
</form>
<main class="assistant-shell">
<section id="login-view" class="auth-view">
<div class="auth-card" aria-live="polite">
<div class="brand-block">
<span class="brand-mark" aria-hidden="true">Д</span>
<div>
<p class="brand-kicker">личный диалог</p>
<h1>Домовой</h1>
</div>
</div>
<p class="auth-copy">Войдите, чтобы открыть спокойный чат с вашим помощником.</p>
<form id="login-form" class="login-form">
<label for="username">Логин</label>
<input id="username" autocomplete="username" required autofocus>
<label for="password">Пароль</label>
<div class="input-with-action">
<input id="password" type="password" autocomplete="current-password" required>
<button id="toggle-password" class="icon-button" type="button" aria-label="Показать пароль">показать</button>
</div>
<button id="login-button" type="submit">Открыть чат</button>
<p id="login-error" role="alert"></p>
</form>
</div>
</section>
<section id="chat-view" class="chat-view" hidden>
<header class="chat-header">
<a href="/chat" aria-label="Домовой">Домовой</a>
<nav class="human-nav" aria-label="Разделы Домового">
<button class="nav-tab active" data-tab="chat-panel" type="button">Чат</button>
<button class="nav-tab" data-tab="today-panel" type="button">Сегодня</button>
<button class="nav-tab" data-tab="history-panel" type="button">История</button>
<a href="/" class="operator-link">Оператор</a>
</nav>
<span id="active-focus">фокус: не выбран</span>
<span id="session-state">сессия активна</span>
<button id="logout" type="button" aria-label="Выйти">↗</button>
<header class="assistant-header">
<div class="header-brand">
<span class="brand-mark" aria-hidden="true">Д</span>
<div>
<p class="brand-kicker">твой спокойный ассистент</p>
<h1>Домовой</h1>
</div>
</div>
<div class="session-state" aria-live="polite">
<span id="connection-state" class="status-pill"></span>
<button id="logout" class="ghost-button" type="button" aria-label="Выйти">Выйти</button>
</div>
</header>
<section id="chat-panel" class="surface-panel active-panel">
<nav class="human-nav" aria-label="Разделы Домового" role="tablist">
<button class="nav-tab active" data-tab="chat-panel" role="tab" aria-selected="true" aria-controls="chat-panel" id="tab-chat" type="button">Чат</button>
<button class="nav-tab" data-tab="today-panel" role="tab" aria-selected="false" aria-controls="today-panel" id="tab-today" type="button">Сегодня</button>
<button class="nav-tab" data-tab="history-panel" role="tab" aria-selected="false" aria-controls="history-panel" id="tab-history" type="button">История</button>
</nav>
<section class="surface-panel active-panel" id="chat-panel" role="tabpanel" aria-labelledby="tab-chat">
<div class="panel-toolbar">
<div>
<h2>Чат</h2>
<p>Пишите обычные фразы. Я отвечу спокойно и по делу.</p>
</div>
<p id="active-focus" class="focus-chip" hidden></p>
</div>
<div id="messages" class="messages" aria-live="polite"></div>
<form id="chat-form" class="composer">
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
<button id="send" type="submit">Отправить</button>
<label class="visually-hidden" for="message">Ваше сообщение</label>
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите, чем помочь" required></textarea>
<div class="composer-actions">
<button id="send" class="primary-button" type="submit">Отправить</button>
</div>
</form>
</section>
<section id="today-panel" class="surface-panel attention-surface" hidden>
<div class="surface-heading">
<p>Сегодня / внимание</p>
<button id="refresh-today" type="button">Обновить</button>
<section id="today-panel" class="surface-panel attention-surface" hidden role="tabpanel" aria-labelledby="tab-today">
<div class="panel-toolbar">
<div>
<h2>Сегодня</h2>
<p id="today-subtitle">Что сейчас требует твоего внимания</p>
</div>
<button id="refresh-today" class="ghost-button" type="button">Обновить</button>
</div>
<div id="today-list" class="attention-list"></div>
</section>
<section id="history-panel" class="surface-panel history-surface" hidden>
<div class="surface-heading">
<p>История</p>
<span>Последние сообщения текущего окна</span>
<section id="history-panel" class="surface-panel history-surface" hidden role="tabpanel" aria-labelledby="tab-history">
<div class="panel-toolbar">
<div>
<h2>История</h2>
<p>Сообщения текущего браузерного сеанса</p>
</div>
<button id="clear-history" class="ghost-button" type="button">Очистить</button>
</div>
<div id="history-list" class="history-list"></div>
</section>
</section>
<p id="status-bar" class="status-bar" aria-live="polite"></p>
</main>
<div id="composer-error" class="composer-error" role="status" aria-live="polite"></div>
<script src="/assets/chat.js"></script>
</body>
</html>