Add EGE support prototype v0.1.0

This commit is contained in:
alexglados1 committed 2026-11-13 14:14:31 +03:00
1 parent 9944a41cca
commit 24cae23262
19 files changed
+2581 -2

No files matched your search

+567
View File
@@ -0,0 +1,567 @@
'use strict';
const byId = (id) => document.getElementById(id);
const exampleField = byId('example');
const actorField = byId('actor');
const messageField = byId('message');
const intake = byId('intake-form');
const submit = byId('submit');
const detail = byId('ticket-detail');
const ticketRows = byId('ticket-rows');
const regularFilter = byId('regular-filter');
const spamFilter = byId('spam-filter');
const panelView = byId('panel-view');
const exitView = byId('panel-exit-view');
const exitButton = byId('panel-exit');
const returnButton = byId('panel-return');
const clearButton = byId('clear-buffer');
let config;
let examples = [];
let tickets = [];
let selectedId = null;
let attempt = null;
let queueRefresh = Promise.resolve();
let folder = 'regular';
let currentDetail = null;
let generation = 0;
let exited = false;
let clearing = false;
let queueTimer;
function isCurrent(expected) {
return !exited && generation === expected;
}
function invalidatePanel() {
generation += 1;
window.clearTimeout(queueTimer);
queueRefresh = Promise.resolve();
}
function element(tag, content = '', className = '') {
const node = document.createElement(tag);
node.textContent = content;
node.className = className;
return node;
}
function option(value, title) {
const node = element('option', title);
node.value = value;
return node;
}
function fillSelect(select, values, selected) {
select.replaceChildren(...Object.entries(values).map(([value, title]) => option(value, title)));
select.value = selected;
}
function feedback(id, text = '', isError = false) {
const status = byId(id);
status.textContent = text;
status.dataset.error = isError;
}
async function api(url, method = 'GET', body) {
const options = { method };
if (body !== undefined) {
options.headers = { 'Content-Type': 'application/json' };
options.body = JSON.stringify(body);
}
let response;
try {
response = await fetch(url, options);
} catch {
throw new Error('Сервер недоступен. Проверьте, что прототип запущен.');
}
let data;
try {
data = await response.json();
} catch {
throw new Error('Сервер вернул ответ в неожиданном формате. Проверьте, что прототип запущен.');
}
if (!response.ok) throw new Error(data.error || 'Не удалось выполнить действие.');
return data;
}
function modeLabel(ticket) {
if (ticket.mode === 'demo') return 'Демо · правила';
return ticket.provider === 'yandex' ? 'LLM · Yandex AI Studio' : 'LLM · OpenAI';
}
function setIntakeDisabled(disabled) {
for (const field of [exampleField, actorField, messageField, submit]) field.disabled = disabled;
intake.setAttribute('aria-busy', String(disabled));
}
exampleField.addEventListener('change', () => {
if (exampleField.value === '') return;
const example = examples[Number(exampleField.value)];
actorField.value = example.actor;
messageField.value = example.text;
feedback('intake-feedback');
});
intake.addEventListener('submit', async (event) => {
event.preventDefault();
if (exited || clearing) return;
const expected = generation;
const actor = actorField.value;
const text = messageField.value.trim();
if (!text) {
feedback('intake-feedback', 'Введите текст обращения.', true);
messageField.focus();
return;
}
if (!attempt || attempt.actor !== actor || attempt.text !== text) {
attempt = { actor, text, requestId: crypto.randomUUID() };
}
setIntakeDisabled(true);
submit.textContent = 'Обрабатываем…';
feedback('intake-feedback');
try {
const { ticket } = await api('/api/tickets', 'POST', attempt);
if (!isCurrent(expected)) return;
attempt = null;
exampleField.value = '';
actorField.value = '';
messageField.value = '';
selectedId = ticket.id;
folder = ticketFolder(ticket);
feedback('intake-feedback', `Обращение №${ticket.id} записано.`);
await refreshQueue().catch((error) => {
if (isCurrent(expected)) feedback('queue-feedback', error.message, true);
});
} catch (error) {
if (isCurrent(expected)) feedback('intake-feedback', error.message, true);
} finally {
if (isCurrent(expected)) {
setIntakeDisabled(false);
submit.textContent = 'Обработать и записать';
}
}
});
const dateFormat = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
function ticketFolder(ticket) {
return ticket.queue === 'spam' || ticket.category === 'spam' ? 'spam' : 'regular';
}
function ticketChat(ticket, draft) {
const section = element('section', '', 'ticket-chat');
const heading = element('h4', 'Переписка');
const scrollWindow = element('div', '', 'chat-window');
const messages = element('ol', '', 'chat-log');
messages.setAttribute('role', 'log');
messages.setAttribute('aria-label', 'Переписка');
const chatStatus = element('p', 'Загрузка переписки…', 'note');
scrollWindow.append(messages);
scrollWindow.hidden = true;
section.append(heading, scrollWindow, chatStatus);
const replyForm = element('form');
const replyLabel = element('label', 'Ответ в чат');
replyLabel.htmlFor = 'chat-reply';
const reply = element('textarea');
reply.id = replyLabel.htmlFor;
reply.rows = 3;
reply.maxLength = 2500;
reply.required = true;
const copyDraft = element('button', 'Вставить черновик', 'secondary');
copyDraft.type = 'button';
copyDraft.addEventListener('click', () => {
reply.value = draft.value;
reply.focus();
});
const send = element('button', 'Отправить в чат');
send.type = 'submit';
const actions = element('div', '', 'reply-actions');
actions.append(send, copyDraft);
const replyStatus = element('p', '', 'feedback');
replyStatus.setAttribute('role', 'status');
replyForm.append(replyLabel, reply, actions);
section.append(replyForm, replyStatus);
const endpoint = `/api/tickets/${ticket.id}/messages`;
const authorLabels = { customer: 'Пользователь', staff: 'Сотрудник', moderator: 'Модерация', operator: 'Поддержка' };
let renderedCount = 0;
let refreshPending;
let replyAttempt;
function updateCategory(category) {
replyForm.hidden = category === 'spam';
}
function renderMessages(items) {
if (!section.isConnected || items.length < renderedCount) return;
const wasAtBottom = scrollWindow.scrollHeight - scrollWindow.scrollTop - scrollWindow.clientHeight < 40;
const fragment = document.createDocumentFragment();
for (const item of items.slice(renderedCount)) {
const bubble = element('li');
bubble.dataset.role = item.role;
const meta = element('div', '', 'chat-meta');
const time = element('time', dateFormat.format(new Date(item.createdAt)));
time.dateTime = item.createdAt;
meta.append(element('strong', authorLabels[item.role] || 'Участник'), time);
bubble.append(meta, element('p', item.text));
fragment.append(bubble);
}
messages.append(fragment);
renderedCount = items.length;
scrollWindow.hidden = renderedCount === 0;
chatStatus.hidden = renderedCount > 0;
chatStatus.textContent = 'Сообщений пока нет.';
if (wasAtBottom) scrollWindow.scrollTop = scrollWindow.scrollHeight;
}
function refresh() {
if (refreshPending) return refreshPending;
refreshPending = api(endpoint).then(({ messages: items }) => renderMessages(items)).catch(() => {
if (!renderedCount) chatStatus.textContent = 'Не удалось загрузить переписку.';
}).finally(() => { refreshPending = null; });
return refreshPending;
}
replyForm.addEventListener('submit', async (event) => {
event.preventDefault();
const text = reply.value.trim();
if (!text || send.disabled || replyForm.hidden || clearing || exited) return;
const expected = generation;
if (replyAttempt?.text !== text) replyAttempt = { requestId: crypto.randomUUID(), text };
send.disabled = true;
copyDraft.disabled = true;
reply.disabled = true;
replyStatus.textContent = '';
try {
const { messages: items } = await api(endpoint, 'POST', replyAttempt);
if (!isCurrent(expected) || !section.isConnected) return;
renderMessages(items);
scrollWindow.scrollTop = scrollWindow.scrollHeight;
replyAttempt = null;
reply.value = '';
replyStatus.textContent = 'Ответ отправлен.';
replyStatus.dataset.error = false;
await refreshQueue(true).catch(() => {});
} catch (error) {
if (isCurrent(expected) && section.isConnected) {
replyStatus.textContent = error.message;
replyStatus.dataset.error = true;
}
} finally {
send.disabled = false;
copyDraft.disabled = false;
reply.disabled = false;
}
});
updateCategory(ticket.category);
return { section, refresh, updateCategory };
}
function renderDetail(ticket) {
currentDetail = null;
detail.replaceChildren();
detail.hidden = !ticket;
if (!ticket) return;
const title = element('h3', `Обращение №${ticket.id}`);
const created = dateFormat.format(new Date(ticket.createdAt));
const meta = element('p', `${ticket.channel} · ${config.actors[ticket.actor]} · ${created} · ${modeLabel(ticket)}`, 'detail-meta');
const text = element('p', ticket.text, 'detail-text');
const reason = element('p', `Первичная оценка. ${ticket.reason}`, 'note');
detail.append(title, meta);
if (ticket.externalId) detail.append(element('p', `ID в источнике ${ticket.externalId}`, 'detail-meta source-id'));
text.hidden = ticket.hasConversation;
const review = element('p', 'Нужна проверка деталей', 'note review');
review.hidden = !ticket.needsReview || ticket.status !== 'new';
detail.append(text, reason, review);
const form = element('form');
const controls = element('div', '', 'controls');
const category = element('select');
category.id = 'edit-category';
fillSelect(category, config.categories, ticket.category);
const status = element('select');
status.id = 'edit-status';
fillSelect(status, config.statuses, ticket.status);
const categoryLabel = element('label', 'Категория');
categoryLabel.htmlFor = category.id;
const statusLabel = element('label', 'Статус');
statusLabel.htmlFor = status.id;
const categoryControl = element('div');
categoryControl.append(categoryLabel, category);
const statusControl = element('div');
statusControl.append(statusLabel, status);
controls.append(categoryControl, statusControl);
form.append(controls);
const impact = element('input');
impact.type = 'checkbox';
impact.checked = ticket.studentImpact;
if (ticket.actor === 'staff') {
const impactLabel = element('label', '', 'checkbox');
impactLabel.append(impact, element('span', 'Сбой уже затрагивает учеников'));
form.append(impactLabel);
}
const draft = element('textarea');
draft.id = 'edit-draft';
draft.rows = 4;
draft.maxLength = 2500;
draft.required = true;
draft.value = ticket.draft;
const draftLabel = element('label');
draftLabel.htmlFor = draft.id;
const updateDraftLabel = () => {
draftLabel.textContent = category.value === 'spam' ? 'Рекомендация оператору' : 'Черновик ответа';
};
category.addEventListener('change', updateDraftLabel);
updateDraftLabel();
const save = element('button', 'Сохранить правку');
save.type = 'submit';
form.append(draftLabel, draft, save);
const thread = ticket.hasConversation ? ticketChat(ticket, draft) : null;
if (thread) detail.append(thread.section);
detail.append(form);
const fieldValues = () => ({
category: category.value, status: status.value, studentImpact: impact.checked, draft: draft.value,
});
let savedValues = fieldValues();
let saving = false;
function applyFields(updated) {
category.value = updated.category;
status.value = updated.status;
impact.checked = updated.studentImpact;
draft.value = updated.draft;
savedValues = fieldValues();
updateDraftLabel();
}
function updateDetail(updated) {
if (!updated || updated.id !== ticket.id) return;
reason.textContent = `Первичная оценка. ${updated.reason}`;
review.hidden = !updated.needsReview || updated.status !== 'new';
thread?.updateCategory(updated.category);
const dirty = Object.entries(fieldValues()).some(([key, value]) => value !== savedValues[key]);
if (!saving && !dirty && !form.contains(document.activeElement)) applyFields(updated);
}
currentDetail = { update: updateDetail, refresh: thread?.refresh };
thread?.refresh();
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (saving || clearing || exited) return;
const expected = generation;
const patch = Object.fromEntries(Object.entries(fieldValues()).filter(([key, value]) => value !== savedValues[key]));
if (!Object.keys(patch).length) return;
const fields = [category, status, impact, draft, save];
saving = true;
for (const field of fields) field.disabled = true;
feedback('queue-feedback');
try {
const { ticket: updated } = await api(`/api/tickets/${ticket.id}`, 'PATCH', patch);
if (!isCurrent(expected) || !form.isConnected) return;
applyFields(updated);
if (selectedId === ticket.id) {
folder = ticketFolder(updated);
tickets = tickets.map((existing) => existing.id === updated.id ? updated : existing);
renderQueue(true);
}
feedback('queue-feedback', `Правки к обращению №${ticket.id} сохранены.`);
await refreshQueue(true).catch(() => {
if (isCurrent(expected)) {
feedback('queue-feedback', `Правки к обращению №${ticket.id} сохранены. Обновите страницу, чтобы загрузить очередь.`);
}
});
} catch (error) {
if (isCurrent(expected) && form.isConnected) feedback('queue-feedback', error.message, true);
} finally {
saving = false;
for (const field of fields) field.disabled = false;
}
});
}
function renderQueue(preserveDetail = false) {
const selected = tickets.find((ticket) => ticket.id === selectedId);
if (!selected) selectedId = null;
const visibleTickets = tickets.filter((ticket) => ticketFolder(ticket) === folder);
const focusedId = ticketRows.contains(document.activeElement) ? document.activeElement.dataset.ticket : null;
const fragment = document.createDocumentFragment();
for (const ticket of visibleTickets) {
const row = element('tr');
const summary = element('td');
const preview = ticket.text.replace(/\s+/gu, ' ');
const short = preview.length > 94 ? `${preview.slice(0, 93)}…` : preview;
const show = element('button', `№${ticket.id} · ${short}`, 'ticket-button');
show.type = 'button';
show.setAttribute('aria-expanded', String(ticket.id === selectedId));
show.setAttribute('aria-controls', detail.id);
show.addEventListener('click', () => {
selectedId = selectedId === ticket.id ? null : ticket.id;
feedback('queue-feedback');
renderQueue();
ticketRows.querySelector(`[data-ticket="${ticket.id}"]`)?.focus();
});
show.dataset.ticket = ticket.id;
summary.append(show, element('span', `${ticket.channel} · ${config.actors[ticket.actor]} · ${config.topics[ticket.topic]}`, 'ticket-meta'));
const category = element('td');
const badge = element('span', config.categories[ticket.category], 'tag');
badge.dataset.category = ticket.category;
category.append(badge);
row.append(summary, category, element('td', config.queues[ticket.queue]), element('td', config.statuses[ticket.status]));
fragment.append(row);
}
ticketRows.replaceChildren(fragment);
byId('empty').hidden = visibleTickets.length > 0;
byId('empty-title').textContent = folder === 'spam' ? 'Папка спама пуста' : 'Обращений пока нет';
byId('empty-note').hidden = folder === 'spam';
byId('table-container').hidden = visibleTickets.length === 0;
byId('counter').textContent = `Всего ${visibleTickets.length} · новых ${visibleTickets.filter((ticket) => ticket.status === 'new').length}`;
regularFilter.setAttribute('aria-pressed', String(folder === 'regular'));
spamFilter.setAttribute('aria-pressed', String(folder === 'spam'));
const spamCount = tickets.length - tickets.filter((ticket) => ticketFolder(ticket) === 'regular').length;
spamFilter.textContent = spamCount ? `Спам (${spamCount})` : 'Спам';
if (focusedId) ticketRows.querySelector(`[data-ticket="${focusedId}"]`)?.focus();
if (preserveDetail && selected) currentDetail?.update(selected);
else renderDetail(selected);
}
function openFolder(next) {
if (!config || folder === next) return;
folder = next;
selectedId = null;
feedback('queue-feedback');
renderQueue();
}
regularFilter.addEventListener('click', () => openFolder('regular'));
spamFilter.addEventListener('click', () => openFolder('spam'));
function refreshQueue(polling = false) {
const expected = generation;
const refresh = queueRefresh.catch(() => {}).then(async () => {
if (!isCurrent(expected)) return;
const { tickets: nextTickets } = await api('/api/tickets');
if (!isCurrent(expected)) return;
const changed = nextTickets.length !== tickets.length || nextTickets.some((ticket, index) => (
ticket.id !== tickets[index].id || ticket.updatedAt !== tickets[index].updatedAt
));
tickets = nextTickets;
if (!polling || changed) renderQueue(polling);
else currentDetail?.update(tickets.find((ticket) => ticket.id === selectedId));
});
queueRefresh = refresh;
return refresh;
}
async function pollQueue() {
const expected = generation;
await Promise.allSettled([refreshQueue(true), currentDetail?.refresh?.()]);
if (isCurrent(expected) && !clearing) scheduleQueuePoll();
}
function scheduleQueuePoll() {
window.clearTimeout(queueTimer);
queueTimer = window.setTimeout(pollQueue, 4000);
}
function resetPanel() {
attempt = null;
selectedId = null;
folder = 'regular';
tickets = [];
exampleField.value = '';
actorField.value = '';
messageField.value = '';
submit.textContent = 'Обработать и записать';
feedback('intake-feedback');
feedback('queue-feedback');
renderQueue();
}
clearButton.addEventListener('click', async () => {
if (exited || clearing || !window.confirm('Удалить все обращения и историю обоих чатов? Это действие нельзя отменить.')) return;
clearing = true;
window.clearTimeout(queueTimer);
clearButton.disabled = true;
exitButton.disabled = true;
panelView.inert = true;
feedback('queue-feedback');
try {
await api('/api/tickets/clear', 'POST', {});
invalidatePanel();
resetPanel();
setIntakeDisabled(false);
feedback('queue-feedback', 'Буфер очищен. Обращения и история чатов удалены.');
await refreshQueue().catch(() => {});
} catch (error) {
feedback('queue-feedback', error.message, true);
} finally {
clearing = false;
clearButton.disabled = false;
exitButton.disabled = false;
panelView.inert = false;
clearButton.focus();
scheduleQueuePoll();
}
});
exitButton.addEventListener('click', () => {
if (clearing) return;
exited = true;
invalidatePanel();
resetPanel();
panelView.hidden = true;
exitView.hidden = false;
exitButton.hidden = true;
byId('mode').hidden = true;
returnButton.focus();
});
returnButton.addEventListener('click', () => {
if (!exited) return;
exited = false;
panelView.hidden = false;
exitView.hidden = true;
exitButton.hidden = false;
byId('mode').hidden = false;
initialize();
exitButton.focus();
});
async function initialize() {
const expected = generation;
clearButton.disabled = true;
setIntakeDisabled(true);
byId('counter').textContent = 'Загрузка обращений';
try {
const [settings, sampleData] = await Promise.all([api('/api/config'), api('/api/examples')]);
if (!isCurrent(expected)) return;
config = settings;
examples = sampleData.examples;
byId('mode').textContent = modeLabel(config);
fillSelect(actorField, { '': 'Выберите тип автора', ...config.actors }, '');
messageField.maxLength = config.maxTextLength;
exampleField.replaceChildren(option('', 'Выбрать из списка'), ...examples.map((example, index) => option(String(index), example.title)));
setIntakeDisabled(false);
await refreshQueue();
if (isCurrent(expected)) {
clearButton.disabled = false;
scheduleQueuePoll();
}
} catch (error) {
if (!isCurrent(expected)) return;
byId('counter').textContent = 'Очередь недоступна';
feedback('queue-feedback', error.message, true);
}
}
initialize();