363 lines
15 KiB
JavaScript
363 lines
15 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 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();
|
|
}
|