pp003/frontend/app.js
2026-05-27 15:55:36 +03:00

513 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M20 21a8 8 0 0 0-16 0"></path><circle cx="12" cy="7" r="4"></circle></svg>',
model: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="4" y="5" width="16" height="14" rx="3"></rect><path d="M9 9h.01"></path><path d="M15 9h.01"></path><path d="M9 14h6"></path><path d="M12 2v3"></path></svg>',
meta: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="M8 15l3-4 3 2 5-7"></path><circle cx="8" cy="15" r="1"></circle><circle cx="11" cy="11" r="1"></circle><circle cx="14" cy="13" r="1"></circle><circle cx="19" cy="6" r="1"></circle></svg>'
};
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 = `
<strong>Краткая общая оценка:</strong> ${escapeHtml(comment)}
<div class="score-grid">
<div class="score-card">
<div class="score-label">Фактическая корректность</div>
<div class="score-value">${factual}/10</div>
</div>
<div class="score-card">
<div class="score-label">Полнота ответа</div>
<div class="score-value">${completeness}/10</div>
</div>
<div class="score-card">
<div class="score-label">Логическая согласованность</div>
<div class="score-value">${logic}/10</div>
</div>
<div class="score-card">
<div class="score-label">Итоговая оценка</div>
<div class="score-value">${overall}/10</div>
</div>
</div>
<strong>Выявленные недостатки:</strong>
<ul class="meta-list">${issues.map((item) => `<li>${escapeHtml(String(item))}</li>`).join("")}</ul>
<strong>Рекомендация:</strong> ${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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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");