"use strict"; const CONFIG = { apiEndpoint: window.location.protocol === "file:" ? "http://localhost:3000/api/message" : "/api/message", maxPromptLength: 2000, typingSpeedMs: 14 }; const els = { messages: document.querySelector("#messages"), messagesScroll: document.querySelector("#messagesScroll"), emptyState: document.querySelector("#emptyState"), promptInput: document.querySelector("#promptInput"), chatForm: document.querySelector("#chatForm"), sendButton: document.querySelector("#sendButton"), sendIcon: document.querySelector("#sendIcon"), stopIcon: document.querySelector("#stopIcon"), sendButtonLabel: document.querySelector("#sendButtonLabel"), metaButton: document.querySelector("#metaButton"), metaButtonText: document.querySelector("#metaButtonText"), modePill: document.querySelector("#modePill"), modeText: document.querySelector("#modeText"), statusText: document.querySelector("#statusText"), statusDot: document.querySelector("#statusDot"), notice: document.querySelector("#notice"), limitHint: document.querySelector("#limitHint"), messageTemplate: document.querySelector("#messageTemplate") }; const state = { phase: "idle", abortController: null, activeTimer: null, activeMessage: null, activeText: "", lastUserRequest: "", lastModelAnswer: "", sessionId: null // присваивается сервером при первом запросе, сбрасывается при перезагрузке }; const icons = { user: '', model: '', meta: '' }; function setNotice(text, tone = "warning") { els.notice.textContent = text; els.notice.classList.toggle("is-visible", Boolean(text)); els.notice.classList.toggle("error", tone === "error"); } function setStatus(text, tone = "ready") { const colors = { ready: ["var(--primary)", "var(--primary-soft)"], busy: ["var(--accent)", "var(--accent-soft)"], meta: ["#6d49b8", "var(--meta-bg)"], error: ["var(--danger)", "var(--danger-soft)"] }; const pair = colors[tone] || colors.ready; els.statusText.textContent = text; els.statusDot.style.background = pair[0]; els.statusDot.style.boxShadow = `0 0 0 4px ${pair[1]}`; } function setMode(mode) { const meta = mode === "meta"; els.modePill.classList.toggle("meta", meta); els.modeText.textContent = meta ? "Мета-мониторинг" : "Стандартный ответ"; } function updateInputHeight() { els.promptInput.style.height = "auto"; const maxHeight = 180; const nextHeight = Math.min(els.promptInput.scrollHeight, maxHeight); els.promptInput.style.height = `${nextHeight}px`; els.promptInput.style.overflowY = els.promptInput.scrollHeight > maxHeight ? "auto" : "hidden"; } function promptValidation() { const raw = els.promptInput.value; const text = raw.trim(); if (!text) { return { ok: false, text, reason: "empty", message: "Пустой запрос не отправляется модели." }; } if (raw.length > CONFIG.maxPromptLength) { return { ok: false, text, reason: "too_long", message: `Запрос слишком длинный: ${raw.length} символов из ${CONFIG.maxPromptLength}. Сократите текст.` }; } return { ok: true, text }; } function updatePromptLimit() { const length = els.promptInput.value.length; const tooLong = length > CONFIG.maxPromptLength; els.limitHint.textContent = `${length}/${CONFIG.maxPromptLength} символов`; els.limitHint.classList.toggle("too-long", tooLong); els.promptInput.classList.toggle("input-error", tooLong); } function isBusy() { return state.phase === "generating_standard" || state.phase === "generating_meta"; } function updateControls() { const validation = promptValidation(); const canSend = validation.ok && !isBusy(); const canStop = state.phase === "generating_standard"; els.sendButton.disabled = !canSend && !canStop; els.sendButton.classList.toggle("stop", canStop); els.sendIcon.hidden = canStop; els.stopIcon.hidden = !canStop; els.sendButton.title = canStop ? "Остановить генерацию" : "Отправить"; els.sendButtonLabel.textContent = canStop ? "Остановить генерацию" : "Отправить сообщение"; const metaVisible = Boolean(state.lastModelAnswer.trim()); els.metaButton.classList.toggle("is-visible", metaVisible); els.metaButton.disabled = !metaVisible || state.phase === "generating_standard" || state.phase === "meta_completed"; els.metaButtonText.textContent = state.phase === "generating_meta" ? "Остановить мета-мониторинг" : state.phase === "meta_completed" ? "Мета-мониторинг выполнен" : "Провести мета-мониторинг ответа"; } function scrollToBottom() { els.messagesScroll.scrollTop = els.messagesScroll.scrollHeight; } function hideEmptyState() { if (els.emptyState && els.emptyState.isConnected) { els.emptyState.remove(); } } function makeMessage(role, sender, text, stateText = "") { hideEmptyState(); const node = els.messageTemplate.content.firstElementChild.cloneNode(true); node.classList.add(role); node.querySelector(".avatar").innerHTML = icons[role] || icons.model; node.querySelector(".sender").textContent = sender; node.querySelector(".state").textContent = stateText; node.querySelector(".bubble-text").textContent = text || ""; els.messages.appendChild(node); renderMathInNode(node.querySelector(".bubble-text")); scrollToBottom(); return node; } function setMessageText(node, text, typing = false) { const body = node.querySelector(".bubble-text"); body.textContent = text; body.classList.toggle("typing-caret", typing); if (!typing) { renderMathInNode(body); } scrollToBottom(); } function setMessageState(node, text) { node.querySelector(".state").textContent = text || ""; } function clearActiveTimer() { if (state.activeTimer) { clearInterval(state.activeTimer); state.activeTimer = null; } } function normalizeError(error) { if (error.name === "AbortError") { return "Запрос остановлен пользователем."; } if (error.message) { return error.message; } return "Произошла неизвестная ошибка."; } async function postJson(payload, expectedType, signal) { let response; try { response = await fetch(CONFIG.apiEndpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal }); } catch (error) { if (error.name === "AbortError") throw error; throw new Error("Нет соединения с сервером. Проверьте, что backend запущен и доступен."); } let data = null; try { data = await response.json(); } catch { throw new Error("Сервер вернул ответ не в формате JSON."); } if (!response.ok) { throw new Error(data.error || data.message || `Ошибка сервера: HTTP ${response.status}.`); } if (!data || data.type !== expectedType) { throw new Error(`Сервер вернул неожиданный тип ответа: ${data && data.type ? data.type : "type отсутствует"}.`); } if (data.status && data.status !== "completed") { throw new Error(data.error || data.message || `Операция завершилась со статусом ${data.status}.`); } return data; } function typeIntoMessage(node, fullText, onDone) { clearActiveTimer(); let index = 0; state.activeText = ""; state.activeTimer = setInterval(() => { index += Math.max(1, Math.round(fullText.length / 180)); state.activeText = fullText.slice(0, index); setMessageText(node, state.activeText, index < fullText.length); if (index >= fullText.length) { clearActiveTimer(); setMessageText(node, fullText, false); onDone(fullText); } }, CONFIG.typingSpeedMs); } function renderMetaResult(node, result) { const issues = Array.isArray(result.issues) && result.issues.length ? result.issues : Array.isArray(result.deficiencies) && result.deficiencies.length ? result.deficiencies : ["Сервер не передал отдельный перечень недостатков."]; const comment = result.comment || result.summary || "Краткое пояснение оценки не передано сервером."; const recommendations = result.recommendations || "Рекомендации по улучшению ответа не переданы сервером."; const factual = Number.isFinite(Number(result.factual_correctness)) ? Number(result.factual_correctness) : 0; const completeness = Number.isFinite(Number(result.completeness)) ? Number(result.completeness) : 0; const logic = Number.isFinite(Number(result.logical_consistency)) ? Number(result.logical_consistency) : 0; const overall = Number.isFinite(Number(result.overall_score)) ? Number(result.overall_score) : 0; const body = node.querySelector(".bubble-text"); body.classList.remove("typing-caret"); body.innerHTML = ` Краткая общая оценка: ${escapeHtml(comment)}