Домовой
- +личный диалог
+Домовой
+Войдите, чтобы открыть спокойный чат с вашим помощником.
+ +твой спокойный ассистент
+Домовой
+Сегодня / внимание
- + +История
- Последние сообщения текущего окна + +diff --git a/cmd/creator-web/static/assets/chat.css b/cmd/creator-web/static/assets/chat.css index a1c36df..7be0a19 100644 --- a/cmd/creator-web/static/assets/chat.css +++ b/cmd/creator-web/static/assets/chat.css @@ -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; + } } diff --git a/cmd/creator-web/static/assets/chat.js b/cmd/creator-web/static/assets/chat.js index 70c6dd1..4bf0a89 100644 --- a/cmd/creator-web/static/assets/chat.js +++ b/cmd/creator-web/static/assets/chat.js @@ -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) { diff --git a/cmd/creator-web/static/chat.html b/cmd/creator-web/static/chat.html index 605fe98..874f768 100644 --- a/cmd/creator-web/static/chat.html +++ b/cmd/creator-web/static/chat.html @@ -5,60 +5,101 @@ -
личный диалог
+Войдите, чтобы открыть спокойный чат с вашим помощником.
+ +твой спокойный ассистент
+Сегодня / внимание
- + +История
- Последние сообщения текущего окна + +