513 lines
18 KiB
JavaScript
513 lines
18 KiB
JavaScript
"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("&", "&")
|
||
.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");
|