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 logoutButton = document.querySelector("#logout"); const activeFocus = document.querySelector("#active-focus"); 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 conversationStarters = Array.from(document.querySelectorAll("[data-starter]")); const aiDataForm = document.querySelector("#ai-data-form"); const aiDataInput = document.querySelector("#ai-data"); const aiDataError = document.querySelector("#ai-data-error"); let token = sessionStorage.getItem("domovoy.chat.token") || ""; let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || ""; let localHistory = []; let cognitiveStatusTimer = null; 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) { if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) { expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор."); return; } 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; if (!messages.children.length) { appendMessage("assistant", "Я рядом. Напишите обычное сообщение, без внутренних идентификаторов."); } startCognitiveStatusPolling(); messageInput.focus(); } function showLogin() { stopCognitiveStatusPolling(); chatView.hidden = true; loginView.hidden = false; } 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; } 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(); } 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); sendButton.disabled = true; try { 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 (/что сейчас требует моего внимания/i.test(clean)) { await loadToday(); activateTab("today-panel"); } } catch (error) { 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 { sendButton.disabled = false; 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(); const text = messageInput.value.trim(); if (!text) return; messageInput.value = ""; await sendChatMessage(text); }); for (const tab of navTabs) { tab.addEventListener("click", () => activateTab(tab.dataset.tab)); } for (const starter of conversationStarters) { starter.addEventListener("click", async () => { const text = starter.dataset.starter || ""; if (!text || sendButton.disabled) return; messageInput.value = ""; await sendChatMessage(text); }); } 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 || sendButton.disabled) return; if (containsSecretShapedText(details)) { showAIDataError("Не отправляю секреты. Удалите пароль, токен, API-ключ или приватный ключ и оставьте только описание модели."); return; } showAIDataError(); aiDataInput.value = ""; await sendChatMessage(`Передаю сведения о локальном ИИ для текущего разговора:\n${details}`); }); } if (aiDataInput) aiDataInput.addEventListener("input", () => showAIDataError()); logoutButton.addEventListener("click", () => { expireSession(); }); if (token) { showChat(); } else { showLogin(); }