"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)}
Фактическая корректность
${factual}/10
Полнота ответа
${completeness}/10
Логическая согласованность
${logic}/10
Итоговая оценка
${overall}/10
Выявленные недостатки: Рекомендация: ${escapeHtml(recommendations)} `; renderMathInNode(body); scrollToBottom(); } function renderMathInNode(node) { if (!node || typeof window.renderMathInElement !== "function") { return; } window.renderMathInElement(node, { delimiters: [ { left: "$$", right: "$$", display: true }, { left: "\\[", right: "\\]", display: true }, { left: "\\(", right: "\\)", display: false }, { left: "$", right: "$", display: false } ], throwOnError: false, strict: "ignore", trust: false }); } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } async function startStandardRequest(userRequest) { setNotice(""); setMode("standard"); setStatus("Ожидание ответа модели", "busy"); state.phase = "generating_standard"; state.lastUserRequest = userRequest; state.lastModelAnswer = ""; state.abortController = new AbortController(); updateControls(); makeMessage("user", "Пользователь", userRequest); const assistant = makeMessage("model", "Языковая модель", "Ожидание ответа сервера...", "ожидание"); state.activeMessage = assistant; const payload = { type: "standard_request", user_request: userRequest, mode: "standard", session_id: state.sessionId }; try { const data = await postJson(payload, "standard_answer", state.abortController.signal); const answer = data.answer || ""; if (!answer.trim()) { throw new Error("Сервер вернул пустой ответ языковой модели."); } if (data.session_id) { state.sessionId = data.session_id; } setStatus("Генерация стандартного ответа", "busy"); setMessageState(assistant, "генерация"); typeIntoMessage(assistant, answer, (text) => { state.phase = "standard_completed"; state.lastModelAnswer = text; state.activeMessage = null; state.abortController = null; setMessageState(assistant, "завершено"); setStatus("Готов к мета-мониторингу", "ready"); updateControls(); }); } catch (error) { handleRequestError(error, assistant); } } async function startMetaRequest() { if (!state.lastModelAnswer.trim()) { setNotice("Мета-мониторинг доступен только после получения ответа стандартной модели.", "error"); return; } setNotice(""); setMode("meta"); setStatus("Выполняется мета-мониторинг", "meta"); state.phase = "generating_meta"; state.abortController = new AbortController(); updateControls(); const meta = makeMessage("meta", "Агент мета-мониторинга", "Ожидание результата проверки...", "анализ"); state.activeMessage = meta; const payload = { type: "meta_monitoring_request", user_request: state.lastUserRequest, model_answer: state.lastModelAnswer, mode: "meta_monitoring", session_id: state.sessionId }; try { const data = await postJson(payload, "meta_monitoring_result", state.abortController.signal); renderMetaResult(meta, data); setMessageState(meta, "завершено"); state.phase = "meta_completed"; state.activeMessage = null; state.abortController = null; setStatus("Мета-мониторинг завершен", "ready"); setMode("standard"); updateControls(); } catch (error) { handleRequestError(error, meta); } } function handleRequestError(error, node) { clearActiveTimer(); const message = normalizeError(error); const aborted = error.name === "AbortError"; const wasStandard = state.phase === "generating_standard"; if (node) { setMessageText(node, aborted ? "Операция остановлена пользователем." : message, false); setMessageState(node, aborted ? "остановлено" : "ошибка"); } if (wasStandard) { state.lastModelAnswer = aborted ? state.activeText : ""; } state.phase = aborted && wasStandard && state.lastModelAnswer.trim() ? "standard_completed" : "idle"; state.activeMessage = null; state.abortController = null; setNotice(message, aborted ? "warning" : "error"); setStatus(aborted ? "Операция остановлена" : "Ошибка выполнения", aborted ? "ready" : "error"); setMode("standard"); updateControls(); } function stopActiveOperation() { if (state.phase === "generating_standard" && state.activeTimer) { clearActiveTimer(); state.lastModelAnswer = state.activeText || ""; if (state.activeMessage) { setMessageText( state.activeMessage, state.lastModelAnswer || "Генерация остановлена до получения текста.", false ); setMessageState(state.activeMessage, "остановлено"); } state.phase = state.lastModelAnswer.trim() ? "standard_completed" : "idle"; state.activeMessage = null; state.abortController = null; setStatus("Генерация остановлена", "ready"); setMode("standard"); updateControls(); return; } if (state.abortController) { state.abortController.abort(); } clearActiveTimer(); } els.promptInput.addEventListener("input", () => { updateInputHeight(); updatePromptLimit(); const validation = promptValidation(); if (validation.reason === "too_long") { setNotice(validation.message, "error"); } else if (!isBusy()) { setNotice(""); } updateControls(); }); els.promptInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); els.chatForm.requestSubmit(); } }); els.chatForm.addEventListener("submit", (event) => { event.preventDefault(); if (state.phase === "generating_standard") { stopActiveOperation(); return; } if (state.phase === "generating_meta") { setNotice("Дождитесь завершения мета-мониторинга или остановите его отдельной кнопкой.", "warning"); return; } const validation = promptValidation(); if (!validation.ok) { setNotice(validation.message, validation.reason === "empty" ? "warning" : "error"); updateControls(); return; } els.promptInput.value = ""; updateInputHeight(); updatePromptLimit(); startStandardRequest(validation.text); }); els.metaButton.addEventListener("click", () => { if (state.phase === "generating_meta") { stopActiveOperation(); return; } if (isBusy()) { setNotice("Нельзя запускать мета-мониторинг во время другой операции.", "warning"); return; } startMetaRequest(); }); updateInputHeight(); updatePromptLimit(); updateControls(); setStatus("Готов к работе", "ready");