557 lines
17 KiB
JavaScript
557 lines
17 KiB
JavaScript
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");
|
|
|
|
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", "Я рядом. Пишите просто и по-человечески, я всё пойму.");
|
|
}
|
|
|
|
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 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 = 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 `Передал дальше: ${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 "завершилось ошибкой";
|
|
default:
|
|
return String(state || "обрабатывается").toLowerCase();
|
|
}
|
|
}
|
|
|
|
function renderHistory() {
|
|
if (!historyList) return;
|
|
|
|
historyList.replaceChildren();
|
|
const items = localHistory.slice(-50).slice().reverse();
|
|
|
|
if (!items.length) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "empty-state";
|
|
empty.textContent = "История появится после первых сообщений в этом окне.";
|
|
historyList.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
items.forEach((item) => {
|
|
const row = document.createElement("article");
|
|
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 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 || todayRequestInFlight) return;
|
|
|
|
todayRequestInFlight = true;
|
|
todayList.replaceChildren();
|
|
renderTodayEmpty("Собираю актуальные напоминания…");
|
|
|
|
try {
|
|
const result = await api("/api/today", { method: "GET" });
|
|
const items = result.Items || result.items || [];
|
|
|
|
todayList.replaceChildren();
|
|
|
|
if (!items.length) {
|
|
todaySubtitle.textContent = "Сейчас спокойный период. Нечего тревожного";
|
|
renderTodayEmpty("Сейчас ничего срочного не требует внимания.");
|
|
return;
|
|
}
|
|
|
|
todaySubtitle.textContent = `Найдено записей: ${items.length}`;
|
|
|
|
for (const item of items) {
|
|
todayList.appendChild(createTodayCard(item));
|
|
}
|
|
} catch (error) {
|
|
todayList.replaceChildren();
|
|
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);
|
|
}
|
|
|
|
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,
|
|
}),
|
|
});
|
|
|
|
conversationID = result.conversation_id;
|
|
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");
|
|
}
|
|
|
|
showToast("Сообщение отправлено");
|
|
} catch (error) {
|
|
appendMessage("assistant", "Я не могу отправить сообщение в этот момент. Проверьте сеть и авторизацию.");
|
|
showError(error.message);
|
|
} finally {
|
|
sendButton.disabled = false;
|
|
sendButton.textContent = originalLabel;
|
|
messageInput.disabled = false;
|
|
if (!options.silentUser) {
|
|
messageInput.focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
function activateTab(panelID) {
|
|
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();
|
|
}
|
|
}
|
|
|
|
loginForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const username = usernameInput.value.trim();
|
|
const password = passwordInput.value;
|
|
const loginButton = document.querySelector("#login-button");
|
|
|
|
loginError.textContent = "";
|
|
loginButton.disabled = true;
|
|
loginButton.textContent = "Проверяю…";
|
|
|
|
try {
|
|
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 = "Не удалось войти. Проверьте логин и пароль.";
|
|
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();
|
|
if (!text) return;
|
|
messageInput.value = "";
|
|
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("keydown", navigateTabs);
|
|
}
|
|
|
|
refreshToday?.addEventListener("click", loadToday);
|
|
|
|
clearHistoryButton?.addEventListener("click", () => {
|
|
localHistory = [];
|
|
messages.replaceChildren();
|
|
renderHistory();
|
|
showToast("История очищена");
|
|
});
|
|
|
|
logoutButton?.addEventListener("click", () => {
|
|
endSession("Вы вышли из сеанса");
|
|
});
|
|
|
|
if (token) {
|
|
showChat();
|
|
} else {
|
|
showLogin();
|
|
}
|