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 messages = document.querySelector("#messages"); const chatForm = document.querySelector("#chat-form"); const messageInput = document.querySelector("#message"); const sendButton = document.querySelector("#send"); const sendLabel = document.querySelector("#send-label"); const messageCount = document.querySelector("#message-count"); const logoutButton = document.querySelector("#logout"); const passwordDialog = document.querySelector("#password-dialog"); const passwordForm = document.querySelector("#password-form"); const passwordClose = document.querySelector("#password-close"); const passwordLogout = document.querySelector("#password-logout"); const passwordStatus = document.querySelector("#password-status"); const activeFocus = document.querySelector("#active-focus"); const rightFocus = document.querySelector("#right-focus"); const headerFocus = document.querySelector("#header-focus"); const navToggle = document.querySelector("#nav-toggle"); const chatLayout = document.querySelector(".chat-layout"); const contextToggle = document.querySelector("#context-toggle"); const contextClose = document.querySelector("#context-close"); const contextBackdrop = document.querySelector("#context-backdrop"); const contextDrawer = document.querySelector("#context-drawer"); const newConversationButton = document.querySelector(".new-conversation"); const cognitiveStatus = document.querySelector("#cognitive-status"); const cognitiveConsoleLink = document.querySelector("#cognitive-console-link"); const todayList = document.querySelector("#today-list"); const refreshToday = document.querySelector("#refresh-today"); const historyList = document.querySelector("#history-list"); const navTabs = Array.from(document.querySelectorAll(".nav-tab")); const aiDataForm = document.querySelector("#ai-data-form"); const aiDataInput = document.querySelector("#ai-data"); const aiDataError = document.querySelector("#ai-data-error"); const attachToggle = document.querySelector("#attach-toggle"); const attachmentMenu = document.querySelector("#attachment-menu"); const photoTrigger = document.querySelector("#photo-trigger"); const micTrigger = document.querySelector("#mic-trigger"); const sendIcon = document.querySelector("#send-icon"); const stopIcon = document.querySelector("#stop-icon"); let token = sessionStorage.getItem("domovoy.chat.token") || ""; let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || ""; let localHistory = []; let cognitiveStatusTimer = null; let activeRequestController = null; let composingMessage = false; function resizeComposerInput() { if (!messageInput) return; messageInput.style.height = "auto"; const maxHeight = 178; messageInput.style.height = `${Math.min(messageInput.scrollHeight, maxHeight)}px`; messageInput.style.overflowY = messageInput.scrollHeight > maxHeight ? "auto" : "hidden"; if (messageCount) messageCount.textContent = `${messageInput.value.length.toLocaleString("ru-RU")} / 16 384`; } function isChatBusy() { return sendButton?.getAttribute("aria-busy") === "true"; } function setChatBusy(busy) { if (!sendButton) return; sendButton.setAttribute("aria-busy", busy ? "true" : "false"); sendButton.setAttribute("aria-label", busy ? "Остановить обработку" : "Отправить"); sendButton.title = busy ? "Остановить обработку" : "Отправить"; if (sendLabel) sendLabel.textContent = busy ? "Остановить обработку" : "Отправить"; if (sendIcon) sendIcon.hidden = busy; if (stopIcon) stopIcon.hidden = !busy; } class ChatApiError extends Error { constructor(status, detail = "") { super(detail || `chat api returned ${status}`); this.name = "ChatApiError"; this.status = status; } } function containsSecretShapedText(value) { return /(-----BEGIN [A-Z ]+PRIVATE KEY-----|\b(?:password|passwd|пароль|token|токен|api[_ -]?key|секрет|ключ)\s*[:=]|\bsk-[a-zA-Z0-9_-]{12,})/i.test(value); } function showAIDataError(message = "") { if (!aiDataError) return; aiDataError.textContent = message; aiDataError.hidden = !message; } function renderCognitiveStatus(state) { if (!cognitiveStatus) return; const normalized = String(state || "UNKNOWN").toUpperCase(); let displayState = "degraded"; if (normalized === "AVAILABLE") displayState = "available"; if (normalized === "UNAVAILABLE") displayState = "unavailable"; const labels = { available: "канал мышления доступен", degraded: "канал мышления работает с ограничениями", unavailable: "Домовой рядом · основной канал недоступен", }; cognitiveStatus.dataset.state = displayState; cognitiveStatus.textContent = labels[displayState]; cognitiveStatus.title = displayState === "available" ? "Канал доступен. Нажмите, чтобы проверить ещё раз" : "Проверить канал ещё раз"; if (cognitiveConsoleLink) cognitiveConsoleLink.hidden = displayState === "available"; } async function loadCognitiveStatus() { if (!cognitiveStatus || !token) return; cognitiveStatus.dataset.state = "checking"; cognitiveStatus.textContent = "проверяю канал мышления"; try { const result = await api("/api/capabilities/cognitive-proposal/health", {method: "GET"}); renderCognitiveStatus(result.State || result.state || "UNKNOWN"); } catch (error) { // Cognitive health is an auxiliary probe. Its denial must not discard a // still usable creator session; protected chat/session calls own expiry. renderCognitiveStatus("UNAVAILABLE"); } } function startCognitiveStatusPolling() { if (cognitiveStatusTimer) return; loadCognitiveStatus(); cognitiveStatusTimer = window.setInterval(loadCognitiveStatus, 30000); } function stopCognitiveStatusPolling() { if (!cognitiveStatusTimer) return; window.clearInterval(cognitiveStatusTimer); cognitiveStatusTimer = null; } function showChat() { loginView.hidden = true; chatView.hidden = false; startCognitiveStatusPolling(); messageInput.focus(); resizeComposerInput(); } function showLogin() { stopCognitiveStatusPolling(); chatView.hidden = true; loginView.hidden = false; } function appendMessage(kind, text, meta = "", media = null) { const node = document.createElement("div"); node.className = `message ${kind}`; const head = document.createElement("div"); head.className = "message-head"; const role = document.createElement("strong"); role.className = "message-role"; role.textContent = kind === "user" ? "Вы" : "Домовой"; const time = document.createElement("time"); time.className = "message-time"; const now = new Date(); time.dateTime = now.toISOString(); time.textContent = now.toLocaleTimeString("ru-RU", {hour: "2-digit", minute: "2-digit"}); head.append(role, time); node.appendChild(head); const content = document.createElement("div"); content.className = "message-content"; content.textContent = text; node.appendChild(content); if (meta) { const small = document.createElement("small"); small.textContent = meta; node.appendChild(small); } if (media?.data && media?.contentType) { const image = document.createElement("img"); image.className = "generated-image"; image.alt = text || "Сгенерированное изображение"; image.src = `data:${media.contentType};base64,${media.data}`; node.appendChild(image); } 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; if (rightFocus) rightFocus.textContent = value || "не выбран"; if (headerFocus) headerFocus.textContent = value || "Новый разговор"; } function setContextOpen(open) { if (!contextDrawer) return; contextDrawer.classList.toggle("drawer-open", open); contextDrawer.setAttribute("aria-hidden", String(!open)); if (contextToggle) contextToggle.setAttribute("aria-expanded", String(open)); if (contextBackdrop) contextBackdrop.hidden = !open; } if (contextToggle) contextToggle.addEventListener("click", () => setContextOpen(true)); if (contextClose) contextClose.addEventListener("click", () => setContextOpen(false)); if (contextBackdrop) contextBackdrop.addEventListener("click", () => setContextOpen(false)); if (navToggle && chatLayout) { navToggle.addEventListener("click", () => { const mobile = window.matchMedia("(max-width: 780px)").matches; const open = mobile ? chatLayout.classList.toggle("nav-open") : !chatLayout.classList.toggle("nav-collapsed"); navToggle.setAttribute("aria-expanded", String(open)); navToggle.title = open ? "Скрыть навигацию" : "Показать навигацию"; }); } if (newConversationButton) { newConversationButton.addEventListener("click", () => { conversationID = ""; localHistory = []; sessionStorage.removeItem("domovoy.chat.conversation"); messages.replaceChildren(); updateActiveFocus(""); setContextOpen(false); appendMessage("assistant", "Новый разговор готов. Чем займёмся?"); messageInput.focus(); }); } 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 ChatApiError(response.status, await response.text()); return response.json(); } async function uploadMedia(path, file) { const body = new FormData(); body.append("file", file, file.name); const headers = {}; if (token) headers.Authorization = `Bearer ${token}`; const response = await fetch(path, {method: "POST", headers, body}); if (!response.ok) throw new ChatApiError(response.status, await response.text()); return response.json(); } function expireSession(message = "") { token = ""; conversationID = ""; sessionStorage.removeItem("domovoy.chat.token"); sessionStorage.removeItem("domovoy.chat.conversation"); messages.replaceChildren(); updateActiveFocus(""); if (message) loginError.textContent = message; showLogin(); } 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 "Я разложил это как работу."; return ""; } function humanDelegationState(state) { switch (state) { case "RUNNING": return "работа запущена"; case "WAITING": return "ожидаю следующий шаг"; case "BLOCKED": return "нужно решение человека"; case "VERIFYING": return "результат проверяется"; case "COMPLETED": return "работа завершена"; case "FAILED": return "run завершился ошибкой"; default: return state.toLowerCase(); } } function renderHistory() { if (!historyList) return; historyList.replaceChildren(); const items = localHistory.slice(-30).reverse(); if (!items.length) { const empty = document.createElement("p"); empty.className = "empty-state"; empty.textContent = "История появится после сообщений в этом окне."; historyList.appendChild(empty); return; } for (const item of items) { const row = document.createElement("article"); row.className = `history-item ${item.kind}`; const title = document.createElement("strong"); title.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); historyList.appendChild(row); } } async function loadToday() { if (!todayList || !token) return; todayList.replaceChildren(); const loading = document.createElement("p"); loading.className = "empty-state"; loading.textContent = "Смотрю, что требует внимания..."; todayList.appendChild(loading); try { const result = await api("/api/today", {method: "GET"}); todayList.replaceChildren(); const items = result.Items || result.items || []; if (!items.length) { const empty = document.createElement("p"); empty.className = "empty-state"; empty.textContent = "Сейчас ничего срочного не требует внимания."; todayList.appendChild(empty); return; } 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); } } catch { todayList.replaceChildren(); const error = document.createElement("p"); error.className = "empty-state error"; error.textContent = "Внимание сейчас недоступно. Чат можно продолжать."; todayList.appendChild(error); } } async function sendChatMessage(text, options = {}) { const clean = text.trim(); if (!clean) return; if (!options.silentUser) appendMessage("user", clean); activeRequestController = new AbortController(); setChatBusy(true); messages.setAttribute("aria-busy", "true"); try { const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean}), signal: activeRequestController.signal}); conversationID = result.conversation_id; sessionStorage.setItem("domovoy.chat.conversation", conversationID); updateActiveFocus(result.active_focus, result.project_provenance); appendMessage("assistant", result.reply_text || "Сообщение записано.", humanMeta(result), result.generated_image_base64 ? {contentType: result.generated_image_content_type || "image/png", data: result.generated_image_base64} : null); if (/что сейчас требует моего внимания/i.test(clean)) { await loadToday(); activateTab("today-panel"); } } catch (error) { if (error.name === "AbortError") { appendMessage("assistant", "Обработка остановлена."); } else if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) { expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор."); } else if (error instanceof ChatApiError && error.status >= 500) { appendMessage("assistant", "Домовой не смог завершить обработку: основной runtime сейчас недоступен. Встроенный канал проверяется; повторите сообщение через несколько секунд."); } else { appendMessage("assistant", "Домовой не принял сообщение. Проверьте текст и повторите попытку."); } } finally { activeRequestController = null; setChatBusy(false); messages.removeAttribute("aria-busy"); 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")) { const active = panel.id === panelID; panel.hidden = !active; panel.classList.toggle("active-panel", active); } if (panelID === "today-panel") loadToday(); if (panelID === "history-panel") renderHistory(); } loginForm.addEventListener("submit", async (event) => { event.preventDefault(); loginError.textContent = ""; const username = document.querySelector("#username").value; const password = document.querySelector("#password").value; 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); showChat(); } catch { loginError.textContent = "Вход не выполнен"; } }); chatForm.addEventListener("submit", async (event) => { event.preventDefault(); if (isChatBusy()) return; const text = messageInput.value.trim(); if (!text) return; messageInput.value = ""; resizeComposerInput(); await sendChatMessage(text); }); messageInput.addEventListener("input", resizeComposerInput); messageInput.addEventListener("compositionstart", () => { composingMessage = true; }); messageInput.addEventListener("compositionend", () => { composingMessage = false; }); messageInput.addEventListener("keydown", (event) => { if (event.key !== "Enter" || event.shiftKey || event.isComposing || composingMessage) return; event.preventDefault(); if (!isChatBusy()) chatForm.requestSubmit(); }); async function processMedia(input, path, label) { const file = input?.files?.[0]; if (!file || !mediaStatus) return; mediaStatus.textContent = `${label} обрабатывается...`; input.value = ""; try { const result = await uploadMedia(path, file); const text = String(result.text || "").trim(); if (!text) throw new Error("empty media result"); mediaStatus.textContent = "Готово"; await sendChatMessage(`Пользователь передал ${label.toLowerCase()}. Обработай содержание и ответь по смыслу:\n${text}`); } catch (error) { if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) { expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор."); } else { mediaStatus.textContent = `${label} сейчас недоступно.`; } } } const mediaStatus = document.querySelector("#media-status"); const audioInput = document.querySelector("#audio-input"); const imageInput = document.querySelector("#image-input"); const ocrInput = document.querySelector("#ocr-input"); if (audioInput) audioInput.addEventListener("change", () => processMedia(audioInput, "/api/multimodal/transcribe", "Распознанное аудио")); if (imageInput) imageInput.addEventListener("change", () => processMedia(imageInput, "/api/multimodal/understand", "Описание изображения")); if (ocrInput) ocrInput.addEventListener("change", () => processMedia(ocrInput, "/api/multimodal/ocr", "Текст с изображения")); function toggleAttachmentMenu(open) { if (!attachmentMenu) return; const next = typeof open === "boolean" ? open : attachmentMenu.hidden; attachmentMenu.hidden = !next; if (attachToggle) attachToggle.setAttribute("aria-expanded", String(next)); } if (attachToggle) attachToggle.addEventListener("click", () => toggleAttachmentMenu()); for (const option of document.querySelectorAll("[data-input]")) { option.addEventListener("click", () => { const input = document.querySelector(`#${option.dataset.input}`); toggleAttachmentMenu(false); if (input) input.click(); }); } if (photoTrigger && imageInput) photoTrigger.addEventListener("click", () => imageInput.click()); if (micTrigger && audioInput) micTrigger.addEventListener("click", () => audioInput.click()); document.addEventListener("click", (event) => { if (attachmentMenu && !attachmentMenu.hidden && !attachmentMenu.contains(event.target) && !attachToggle?.contains(event.target)) toggleAttachmentMenu(false); }); if (sendButton) { sendButton.addEventListener("click", (event) => { if (!isChatBusy()) return; event.preventDefault(); activeRequestController?.abort(); }); } for (const tab of navTabs) { tab.addEventListener("click", () => activateTab(tab.dataset.tab)); } if (refreshToday) refreshToday.addEventListener("click", loadToday); if (cognitiveStatus) cognitiveStatus.addEventListener("click", loadCognitiveStatus); if (aiDataForm) { aiDataForm.addEventListener("submit", async (event) => { event.preventDefault(); const details = aiDataInput.value.trim(); if (!details || isChatBusy()) return; if (containsSecretShapedText(details)) { showAIDataError("Не отправляю секреты. Удалите пароль, токен, API-ключ или приватный ключ и оставьте только описание модели."); return; } showAIDataError(); aiDataInput.value = ""; await sendChatMessage(`Передаю сведения о локальном ИИ для текущего разговора:\n${details}`); }); } if (aiDataInput) aiDataInput.addEventListener("input", () => showAIDataError()); logoutButton.addEventListener("click", () => passwordDialog?.showModal()); passwordClose?.addEventListener("click", () => passwordDialog?.close()); passwordLogout?.addEventListener("click", () => { passwordDialog?.close(); expireSession(); }); passwordForm?.addEventListener("submit", async (event) => { event.preventDefault(); passwordStatus.textContent = "Сохраняю…"; const submit = document.querySelector("#password-submit"); submit.disabled = true; try { await api("/api/account/password", {method: "POST", body: JSON.stringify({current_password: document.querySelector("#current-password").value, new_password: document.querySelector("#new-password").value})}); passwordForm.reset(); passwordStatus.textContent = "Пароль изменён без перезапуска."; } catch (error) { passwordStatus.textContent = error?.status === 401 ? "Текущий пароль не принят." : "Не удалось изменить пароль."; } finally { submit.disabled = false; } }); if (token) { showChat(); } else { showLogin(); } resizeComposerInput();