brief-rags-bench/static/app.js

1672 lines
53 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.

/**
* Brief Bench - Main Application Logic (SBS Multi-Environment Version)
* Single-page RAG testing interface with support for IFT, PSI, and PROD environments
*/
// ============================================
// Application State
// ============================================
const AppState = {
settings: null,
currentEnvironment: 'ift', // Current active environment: 'ift', 'psi', or 'prod'
// Environment-specific data
environments: {
ift: {
currentRequest: null,
currentResponse: null,
currentAnswerIndex: 0,
annotations: {},
requestTimestamp: null,
requestId: null,
},
psi: {
currentRequest: null,
currentResponse: null,
currentAnswerIndex: 0,
annotations: {},
requestTimestamp: null,
requestId: null,
},
prod: {
currentRequest: null,
currentResponse: null,
currentAnswerIndex: 0,
annotations: {},
requestTimestamp: null,
requestId: null,
}
}
};
// Helper function to get current environment state
function getCurrentEnv() {
return AppState.environments[AppState.currentEnvironment];
}
// Helper function to get current environment settings
function getCurrentEnvSettings() {
return AppState.settings.environments[AppState.currentEnvironment];
}
// ============================================
// Authentication
// ============================================
/**
* Проверить авторизацию при загрузке страницы
*/
async function checkAuth() {
if (!api.isAuthenticated()) {
showLoginScreen()
return false
}
// Попробовать загрузить настройки (валидация токена)
try {
await loadSettingsFromServer()
return true
} catch (error) {
console.error('Token validation failed:', error)
showLoginScreen()
return false
}
}
/**
* Показать экран авторизации
*/
function showLoginScreen() {
document.getElementById('login-screen').style.display = 'flex'
document.getElementById('app').style.display = 'none'
}
/**
* Скрыть экран авторизации и показать приложение
*/
function hideLoginScreen() {
document.getElementById('login-screen').style.display = 'none'
document.getElementById('app').style.display = 'block'
}
/**
* Обработка авторизации
*/
async function handleLogin() {
const loginInput = document.getElementById('login-input')
const loginError = document.getElementById('login-error')
const loginBtn = document.getElementById('login-submit-btn')
const login = loginInput.value.trim()
// Валидация
if (!/^[0-9]{8}$/.test(login)) {
loginError.textContent = 'Логин должен состоять из 8 цифр'
loginError.style.display = 'block'
return
}
loginError.style.display = 'none'
loginBtn.disabled = true
loginBtn.textContent = 'Вход...'
try {
const response = await api.login(login)
console.log('Login successful:', response.user)
// Загрузить настройки с сервера
await loadSettingsFromServer()
// Скрыть login screen, показать приложение
hideLoginScreen()
loginInput.value = ''
} catch (error) {
console.error('Login failed:', error)
loginError.textContent = error.message || 'Ошибка авторизации'
loginError.style.display = 'block'
} finally {
loginBtn.disabled = false
loginBtn.textContent = 'Войти'
}
}
/**
* Выход из системы
*/
function handleLogout() {
if (confirm('Вы уверены, что хотите выйти?')) {
api.logout()
}
}
// ============================================
// Utility Functions
// ============================================
/**
* Generate UUID v4
*/
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* Format time in seconds to human-readable format
*/
function formatTime(seconds) {
if (seconds < 1) {
return `${(seconds * 1000).toFixed(0)} мс`;
} else if (seconds < 60) {
return `${seconds.toFixed(2)} сек`;
} else {
const mins = Math.floor(seconds / 60);
const secs = (seconds % 60).toFixed(0);
return `${mins} мин ${secs} сек`;
}
}
/**
* Format ISO timestamp to readable format
*/
function formatTimestamp(isoString) {
const date = new Date(isoString);
return date.toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
/**
* Detect if text contains a table structure
*/
function isTableText(text) {
if (!text) return false;
const lines = text.split('\n');
let pipeCount = 0;
for (const line of lines) {
if (line.includes('|')) pipeCount++;
}
return pipeCount >= 2; // At least 2 lines with pipes
}
/**
* Parse text table into HTML table
*/
function parseTextTable(text) {
const lines = text.split('\n').filter(line => line.trim());
if (lines.length < 2) return null;
const rows = lines.map(line =>
line.split('|')
.map(cell => cell.trim())
.filter(cell => cell.length > 0)
);
if (rows.length === 0) return null;
// Find separator line (if exists) - typically second line with dashes
let separatorIndex = -1;
for (let i = 0; i < rows.length; i++) {
if (rows[i].every(cell => /^-+$/.test(cell.trim()))) {
separatorIndex = i;
break;
}
}
let thead = '';
let tbody = '';
if (separatorIndex === 1) {
// Header row exists
const headerCells = rows[0].map(cell => `<th>${escapeHtml(cell)}</th>`).join('');
thead = `<thead><tr>${headerCells}</tr></thead>`;
const bodyRows = rows.slice(2).map(row => {
const cells = row.map(cell => `<td>${escapeHtml(cell)}</td>`).join('');
return `<tr>${cells}</tr>`;
}).join('');
tbody = `<tbody>${bodyRows}</tbody>`;
} else {
// No header, all rows are data
const bodyRows = rows.map(row => {
const cells = row.map(cell => `<td>${escapeHtml(cell)}</td>`).join('');
return `<tr>${cells}</tr>`;
}).join('');
tbody = `<tbody>${bodyRows}</tbody>`;
}
return `<table class="rendered-table">${thead}${tbody}</table>`;
}
/**
* Escape HTML special characters
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Download JSON file
*/
function downloadJSON(data, filename) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Show toast notification (simple alert for now)
*/
function showToast(message, type = 'info') {
// TODO: Implement proper toast/snackbar component
console.log(`[${type.toUpperCase()}] ${message}`);
alert(message);
}
// ============================================
// Settings Management
// ============================================
/**
* Загрузить настройки с сервера (DB API)
*/
async function loadSettingsFromServer() {
try {
const response = await api.getSettings()
// Преобразовать в формат AppState.settings
AppState.settings = {
activeEnvironment: AppState.currentEnvironment,
environments: {
ift: {
name: 'ИФТ',
...response.settings.ift
},
psi: {
name: 'ПСИ',
...response.settings.psi
},
prod: {
name: 'ПРОМ',
...response.settings.prod
}
},
requestTimeout: 1800000, // 30 минут (фиксировано)
}
console.log('Settings loaded from server:', AppState.settings)
} catch (error) {
console.error('Failed to load settings from server:', error)
throw error
}
}
/**
* Сохранить настройки на сервер (DB API)
*/
async function saveSettingsToServer(settings) {
try {
// Извлечь только поля, которые сервер ожидает
const settingsToSave = {
ift: extractEnvironmentSettings(settings.environments.ift),
psi: extractEnvironmentSettings(settings.environments.psi),
prod: extractEnvironmentSettings(settings.environments.prod)
}
await api.updateSettings(settingsToSave)
AppState.settings = settings
console.log('Settings saved to server')
} catch (error) {
console.error('Failed to save settings to server:', error)
throw error
}
}
/**
* Извлечь только нужные поля для сервера
*/
function extractEnvironmentSettings(envSettings) {
return {
apiMode: envSettings.apiMode,
bearerToken: envSettings.bearerToken || null,
systemPlatform: envSettings.systemPlatform || null,
systemPlatformUser: envSettings.systemPlatformUser || null,
platformUserId: envSettings.platformUserId || null,
platformId: envSettings.platformId || null,
withClassify: envSettings.withClassify || false,
resetSessionMode: envSettings.resetSessionMode !== false
}
}
/**
* Populate settings dialog with current values
*/
function populateSettingsDialog() {
const env = AppState.currentEnvironment;
const envSettings = AppState.settings.environments[env];
// Set environment selector
document.getElementById('settings-env-selector').value = env;
// API Mode
const apiMode = envSettings.apiMode || 'bench';
document.getElementById('setting-api-mode').value = apiMode;
toggleBackendSettings(apiMode === 'backend');
// Populate environment-specific fields (только редактируемые пользователем)
document.getElementById('setting-bearer-token').value = envSettings.bearerToken || '';
document.getElementById('setting-system-platform').value = envSettings.systemPlatform || '';
document.getElementById('setting-system-platform-user').value = envSettings.systemPlatformUser || '';
// Backend mode fields
document.getElementById('setting-platform-user-id').value = envSettings.platformUserId || '';
document.getElementById('setting-platform-id').value = envSettings.platformId || '';
document.getElementById('setting-with-classify').checked = envSettings.withClassify || false;
document.getElementById('setting-reset-session-mode').checked = envSettings.resetSessionMode !== false;
}
/**
* Toggle visibility of backend settings
*/
function toggleBackendSettings(show) {
const backendSettings = document.getElementById('backend-settings');
const backendHeader = document.getElementById('backend-settings-header');
if (show) {
backendSettings.style.display = 'block';
backendHeader.style.display = 'block';
} else {
backendSettings.style.display = 'none';
backendHeader.style.display = 'none';
}
}
/**
* Read settings from dialog
*/
function readSettingsFromDialog() {
const env = document.getElementById('settings-env-selector').value;
// Update environment-specific settings
const updatedSettings = JSON.parse(JSON.stringify(AppState.settings)); // Deep copy
updatedSettings.environments[env] = {
name: updatedSettings.environments[env].name,
apiMode: document.getElementById('setting-api-mode').value,
bearerToken: document.getElementById('setting-bearer-token').value.trim(),
systemPlatform: document.getElementById('setting-system-platform').value.trim(),
systemPlatformUser: document.getElementById('setting-system-platform-user').value.trim(),
platformUserId: document.getElementById('setting-platform-user-id').value.trim(),
platformId: document.getElementById('setting-platform-id').value.trim(),
withClassify: document.getElementById('setting-with-classify').checked,
resetSessionMode: document.getElementById('setting-reset-session-mode').checked,
};
return updatedSettings;
}
// ============================================
// UI Initialization
// ============================================
/**
* Initialize application
*/
async function initApp() {
// Load settings from server
await loadSettingsFromServer();
AppState.currentEnvironment = AppState.settings.activeEnvironment || AppState.currentEnvironment || 'ift';
// Load saved data for each environment
['ift', 'psi', 'prod'].forEach(env => {
const savedData = localStorage.getItem(`briefBenchData_${env}`);
if (savedData) {
try {
const data = JSON.parse(savedData);
AppState.environments[env] = data;
} catch (e) {
console.error(`Failed to load data for ${env}:`, e);
}
}
});
// Load saved annotations draft for current environment (legacy support)
const savedAnnotations = localStorage.getItem('briefBenchAnnotationsDraft');
if (savedAnnotations) {
try {
const annotations = JSON.parse(savedAnnotations);
// Migrate to new structure if needed
if (!AppState.environments.ift.annotations || Object.keys(AppState.environments.ift.annotations).length === 0) {
AppState.environments.ift.annotations = annotations;
}
} catch (e) {
console.error('Failed to load annotations draft:', e);
}
}
// Setup event listeners
setupEventListeners();
// Set active environment tab
updateEnvironmentTabs();
// Show query builder by default
showQueryBuilder();
console.log('Brief Bench SBS initialized - Environment:', AppState.currentEnvironment);
}
/**
* Setup all event listeners
*/
function setupEventListeners() {
// Prevent double-binding listeners
if (AppState._eventListenersSetup) {
return;
}
AppState._eventListenersSetup = true;
// Environment tabs
document.querySelectorAll('.env-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
const env = e.target.dataset.env;
switchEnvironment(env);
});
});
// Settings environment selector
document.getElementById('settings-env-selector').addEventListener('change', (e) => {
// Save current environment settings first
const currentSettings = readSettingsFromDialog();
AppState.settings = currentSettings;
// Load new environment settings
populateSettingsDialog();
});
// API Mode selector
document.getElementById('setting-api-mode').addEventListener('change', (e) => {
toggleBackendSettings(e.target.value === 'backend');
});
// App bar buttons
document.getElementById('menu-btn').addEventListener('click', toggleDrawer);
document.getElementById('new-query-btn').addEventListener('click', showQueryBuilder);
document.getElementById('settings-btn').addEventListener('click', openSettingsDialog);
document.getElementById('export-btn').addEventListener('click', exportAnalysis);
document.getElementById('import-btn').addEventListener('click', importAnalysis);
// Drawer buttons
document.getElementById('clear-all-btn').addEventListener('click', clearAll);
// Settings dialog
document.getElementById('close-settings-btn').addEventListener('click', closeSettingsDialog);
document.getElementById('save-settings-btn').addEventListener('click', saveSettingsHandler);
// Login
document.getElementById('login-submit-btn').addEventListener('click', handleLogin);
document.getElementById('login-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleLogin();
}
});
// Logout
document.getElementById('logout-btn').addEventListener('click', handleLogout);
document.getElementById('reset-settings-btn').addEventListener('click', resetSettings);
document.getElementById('import-settings-btn').addEventListener('click', importSettings);
document.getElementById('export-settings-btn').addEventListener('click', exportSettings);
// Query builder
document.querySelectorAll('.toggle-option').forEach(btn => {
btn.addEventListener('click', (e) => {
const mode = e.target.dataset.mode;
switchQueryMode(mode);
});
});
document.getElementById('validate-json-btn').addEventListener('click', validateJSON);
document.getElementById('send-query-btn').addEventListener('click', handleSendQuery);
document.getElementById('load-response-btn').addEventListener('click', loadResponseFromFile);
document.getElementById('load-request-btn').addEventListener('click', loadRequestFromFile);
// Tab navigation
document.addEventListener('click', (e) => {
if (e.target.classList.contains('tab')) {
const tabId = e.target.dataset.tab;
if (tabId) {
switchTab(e.target, tabId);
}
}
});
// Annotation changes
document.addEventListener('change', (e) => {
if (e.target.classList.contains('checkbox') ||
e.target.tagName === 'SELECT' ||
e.target.tagName === 'TEXTAREA') {
saveAnnotationsDraft();
}
});
// Close dialog on overlay click
document.getElementById('settings-dialog').addEventListener('click', (e) => {
if (e.target.id === 'settings-dialog') {
closeSettingsDialog();
}
});
}
/**
* Switch to a different environment
*/
function switchEnvironment(env) {
// Save current environment data
saveEnvironmentData(AppState.currentEnvironment);
// Switch environment
AppState.currentEnvironment = env;
AppState.settings.activeEnvironment = env;
// activeEnvironment - это локальное состояние UI, не сохраняем на сервер
// Update UI
updateEnvironmentTabs();
// Reload content for new environment
const currentEnv = getCurrentEnv();
if (currentEnv.currentResponse) {
renderQuestionsList();
renderAnswer(currentEnv.currentAnswerIndex);
} else {
showQueryBuilder();
}
console.log('Switched to environment:', env);
}
/**
* Update environment tabs visual state
*/
function updateEnvironmentTabs() {
document.querySelectorAll('.env-tab').forEach(tab => {
if (tab.dataset.env === AppState.currentEnvironment) {
tab.classList.add('active');
} else {
tab.classList.remove('active');
}
});
}
/**
* Save environment data to localStorage
*/
function saveEnvironmentData(env) {
const data = AppState.environments[env];
localStorage.setItem(`briefBenchData_${env}`, JSON.stringify(data));
}
/**
* Toggle drawer visibility (mobile)
*/
function toggleDrawer() {
const drawer = document.getElementById('drawer');
drawer.classList.toggle('collapsed');
}
/**
* Clear all data and reload page
*/
function clearAll() {
if (confirm('Очистить все данные и обновить страницу? Несохраненные изменения будут потеряны.')) {
window.location.reload();
}
}
/**
* Switch query mode
*/
function switchQueryMode(mode) {
document.querySelectorAll('.toggle-option').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.mode === mode) {
btn.classList.add('active');
}
});
document.getElementById('questions-mode').classList.toggle('hidden', mode !== 'questions');
document.getElementById('raw-json-mode').classList.toggle('hidden', mode !== 'raw-json');
}
/**
* Switch between tabs
*/
function switchTab(tabButton, tabId) {
// Get all tabs in the same group
const tabsContainer = tabButton.parentElement;
const tabs = tabsContainer.querySelectorAll('.tab');
// Deactivate all tabs
tabs.forEach(tab => tab.classList.remove('active'));
// Activate clicked tab
tabButton.classList.add('active');
// Find and show corresponding content
const contentContainer = tabsContainer.nextElementSibling;
if (contentContainer && contentContainer.classList.contains('tab-content')) {
// Handle nested tabs
let parent = tabsContainer.parentElement;
const allContents = parent.querySelectorAll('.tab-content');
allContents.forEach(content => {
if (content.id === tabId) {
content.classList.add('active');
} else if (!content.contains(tabsContainer)) {
content.classList.remove('active');
}
});
} else {
// Handle top-level tabs
const parent = tabsContainer.parentElement;
const allContents = parent.querySelectorAll(':scope > .tab-content');
allContents.forEach(content => {
content.classList.toggle('active', content.id === tabId);
});
// If activated content has nested tabs, ensure first nested tab-content is shown
const activatedContent = document.getElementById(tabId);
if (activatedContent) {
const nestedTabsContainer = activatedContent.querySelector('.tabs');
if (nestedTabsContainer) {
// Activate first nested tab button
const nestedTabs = nestedTabsContainer.querySelectorAll('.tab');
nestedTabs.forEach((tab, index) => {
if (index === 0) {
tab.classList.add('active');
} else {
tab.classList.remove('active');
}
});
// Find nested tab-content elements (immediate children only, after tabs container)
// Use children to get elements in correct DOM order
const children = Array.from(activatedContent.children);
const nestedContents = children.filter(el =>
el.classList.contains('tab-content') &&
el !== nestedTabsContainer
);
// Deactivate all first, then activate first one
nestedContents.forEach(content => content.classList.remove('active'));
if (nestedContents.length > 0) {
nestedContents[0].classList.add('active');
}
}
}
}
}
// ============================================
// Settings Dialog
// ============================================
function openSettingsDialog() {
populateSettingsDialog();
document.getElementById('settings-dialog').classList.add('open');
}
function closeSettingsDialog() {
document.getElementById('settings-dialog').classList.remove('open');
}
async function saveSettingsHandler() {
const saveBtn = document.getElementById('save-settings-btn');
saveBtn.disabled = true;
saveBtn.textContent = 'Сохранение...';
try {
const updatedSettings = readSettingsFromDialog();
await saveSettingsToServer(updatedSettings);
showToast('Настройки сохранены на сервере', 'success');
closeSettingsDialog();
} catch (error) {
console.error('Failed to save settings:', error);
showToast(`Ошибка сохранения: ${error.message}`, 'error');
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Сохранить';
}
}
async function resetSettings() {
if (confirm('Сбросить все настройки к значениям по умолчанию?')) {
try {
AppState.settings = { ...defaultSettings };
await saveSettingsToServer(AppState.settings);
populateSettingsDialog();
showToast('Настройки сброшены и сохранены на сервере', 'success');
} catch (error) {
console.error('Failed to reset settings:', error);
showToast(`Ошибка сброса: ${error.message}`, 'error');
}
}
}
function exportSettings() {
const filename = 'brief-bench-settings.json';
downloadJSON(AppState.settings, filename);
showToast('Настройки экспортированы в ' + filename, 'success');
}
function importSettings() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const settings = JSON.parse(text);
// Validate basic structure
if (typeof settings !== 'object' || settings === null) {
throw new Error('Файл настроек должен содержать JSON объект');
}
// Merge with defaults to ensure all required fields exist
AppState.settings = {
...defaultSettings,
...settings
};
// Save to server
await saveSettingsToServer(AppState.settings);
// Update dialog
populateSettingsDialog();
showToast('Настройки успешно импортированы и сохранены на сервере', 'success');
} catch (error) {
showToast(`Ошибка импорта настроек: ${error.message}`, 'error');
}
};
input.click();
}
// ============================================
// Query Builder
// ============================================
function showQueryBuilder() {
document.getElementById('query-builder').classList.remove('hidden');
document.getElementById('answer-viewer').classList.add('hidden');
}
function validateJSON() {
const textarea = document.getElementById('json-textarea');
const message = document.getElementById('json-validation-message');
try {
const json = JSON.parse(textarea.value);
// Validate schema
if (!Array.isArray(json)) {
throw new Error('JSON должен быть массивом');
}
for (let i = 0; i < json.length; i++) {
const item = json[i];
if (typeof item.body !== 'string') {
throw new Error(`Элемент ${i}: поле "body" должно быть строкой`);
}
if (typeof item.with_docs !== 'boolean') {
throw new Error(`Элемент ${i}: поле "with_docs" должно быть boolean`);
}
}
textarea.classList.remove('error');
message.textContent = `✓ JSON валиден (${json.length} вопросов)`;
message.classList.remove('error');
message.classList.add('color-success');
return true;
} catch (error) {
textarea.classList.add('error');
message.textContent = `✗ Ошибка: ${error.message}`;
message.classList.add('error');
message.classList.remove('color-success');
return false;
}
}
function buildRequestBody() {
const mode = document.querySelector('.toggle-option.active').dataset.mode;
if (mode === 'questions') {
const text = document.getElementById('questions-textarea').value;
const questions = text.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
if (questions.length === 0) {
throw new Error('Введите хотя бы один вопрос');
}
return questions.map(q => ({
body: q,
with_docs: AppState.settings.defaultWithDocs
}));
} else {
const json = document.getElementById('json-textarea').value;
return JSON.parse(json);
}
}
async function handleSendQuery() {
try {
const envSettings = getCurrentEnvSettings();
const env = getCurrentEnv();
const apiMode = envSettings.apiMode || 'bench';
const requestBody = buildRequestBody();
env.currentRequest = requestBody;
// Show loading
const loadingMsg = apiMode === 'backend'
? 'Отправка запроса к Backend API...'
: 'Отправка запроса к Bench API...';
showLoading(loadingMsg);
const currentEnvKey = AppState.currentEnvironment;
let apiResponse;
if (apiMode === 'bench') {
apiResponse = await api.benchQuery(currentEnvKey, requestBody);
// Response format: { request_id, timestamp, environment, response }
env.currentResponse = apiResponse.response;
env.requestId = apiResponse.request_id;
env.requestTimestamp = apiResponse.timestamp;
} else if (apiMode === 'backend') {
const resetSession = envSettings.resetSessionMode !== false;
apiResponse = await api.backendQuery(currentEnvKey, requestBody, resetSession);
// Response format: { request_id, timestamp, environment, response }
env.currentResponse = apiResponse.response;
env.requestId = apiResponse.request_id;
env.requestTimestamp = apiResponse.timestamp;
} else {
throw new Error(`Неизвестный режим API: ${apiMode}`);
}
// Hide loading
hideLoading();
// Validate response
if (!env.currentResponse || !env.currentResponse.answers || !Array.isArray(env.currentResponse.answers)) {
throw new Error('Некорректный формат ответа: отсутствует поле "answers"');
}
env.currentAnswerIndex = 0;
// Initialize annotations for new response
env.annotations = {};
// Save to localStorage
saveEnvironmentData(AppState.currentEnvironment);
// Render UI
renderQuestionsList();
renderAnswer(0);
const modeLabel = apiMode === 'backend' ? 'Backend' : 'Bench';
showToast(`[${modeLabel}] Получено ${env.currentResponse.answers.length} ответов`, 'success');
} catch (error) {
hideLoading();
showToast(`Ошибка: ${error.message}`, 'error');
console.error('Query error:', error);
}
}
/**
* Extract questions from query builder
*/
function extractQuestions() {
const mode = document.querySelector('.toggle-option.active')?.dataset.mode || 'questions';
if (mode === 'questions') {
const text = document.getElementById('questions-textarea').value.trim();
if (!text) {
throw new Error('Введите хотя бы один вопрос');
}
// Split by newlines and filter empty lines
return text.split('\n').filter(line => line.trim().length > 0).map(line => line.trim());
} else {
// Raw JSON mode
const json = document.getElementById('json-textarea').value;
const parsed = JSON.parse(json);
if (Array.isArray(parsed)) {
// If it's array of objects with 'body', extract bodies
if (parsed.length > 0 && typeof parsed[0] === 'object' && parsed[0].body) {
return parsed.map(item => item.body);
}
// If it's array of strings
if (parsed.length > 0 && typeof parsed[0] === 'string') {
return parsed;
}
}
throw new Error('JSON должен быть массивом вопросов (строк или объектов с полем "body")');
}
}
function loadResponseFromFile() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
// Validate response format
if (!data.answers || !Array.isArray(data.answers)) {
throw new Error('Файл должен содержать объект с полем "answers" (массив)');
}
const env = getCurrentEnv();
// Set response
env.currentResponse = data;
env.currentAnswerIndex = 0;
env.requestTimestamp = new Date().toISOString();
env.requestId = 'loaded-' + generateUUID();
env.annotations = {};
// Try to reconstruct request from questions in response
env.currentRequest = data.answers.map(answer => ({
body: answer.question,
with_docs: true
}));
// Save to localStorage
saveEnvironmentData(AppState.currentEnvironment);
renderQuestionsList();
renderAnswer(0);
showToast(`Загружен ответ: ${data.answers.length} вопросов`, 'success');
} catch (error) {
showToast(`Ошибка загрузки ответа: ${error.message}`, 'error');
}
};
input.click();
}
function loadRequestFromFile() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
// Validate it's an array
if (!Array.isArray(data)) {
throw new Error('Файл должен содержать JSON массив');
}
// Load into textarea
document.getElementById('json-textarea').value = JSON.stringify(data, null, 2);
validateJSON();
showToast(`Загружен запрос: ${data.length} вопросов`, 'success');
} catch (error) {
showToast(`Ошибка загрузки запроса: ${error.message}`, 'error');
}
};
input.click();
}
function importAnalysis() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
// Validate it's a full export (analysis file)
if (!data.request || !data.response || !data.response.answers) {
throw new Error('Неверный формат файла анализа. Используйте файл, экспортированный через "Экспорт анализа".');
}
// Determine target environment (default to current if not specified - backward compatibility)
const targetEnv = data.environment || AppState.currentEnvironment;
// Check if we need to switch environment
if (targetEnv !== AppState.currentEnvironment) {
// Save current environment before switching
saveEnvironmentData(AppState.currentEnvironment);
// Switch to target environment
AppState.currentEnvironment = targetEnv;
AppState.settings.activeEnvironment = targetEnv;
// activeEnvironment - это локальное состояние UI, не сохраняем на сервер
updateEnvironmentTabs();
}
const env = getCurrentEnv();
// Restore complete state to target environment
env.currentRequest = data.request;
env.currentResponse = data.response;
env.annotations = data.annotations || {};
env.requestTimestamp = data.settings_snapshot?.timestamp || data.exported_at || new Date().toISOString();
env.requestId = data.settings_snapshot?.requestId || generateUUID();
env.currentAnswerIndex = 0;
// Save to localStorage
saveEnvironmentData(targetEnv);
// Render UI
renderQuestionsList();
renderAnswer(0);
// Count annotations
const annotationCount = Object.keys(env.annotations).filter(key => {
const ann = env.annotations[key];
return ann.overall?.rating || ann.overall?.comment ||
ann.body_research?.issues?.length > 0 ||
ann.body_analytical_hub?.issues?.length > 0;
}).length;
const envName = AppState.settings.environments[targetEnv].name;
const message = `Анализ импортирован в ${envName}: ${data.response.answers.length} вопросов` +
(annotationCount > 0 ? `, ${annotationCount} с пометками` : '');
showToast(message, 'success');
} catch (error) {
showToast(`Ошибка импорта: ${error.message}`, 'error');
}
};
input.click();
}
// ============================================
// Loading Overlay
// ============================================
function showLoading(message) {
document.getElementById('loading-message').textContent = message;
document.getElementById('loading-overlay').classList.add('open');
}
function hideLoading() {
document.getElementById('loading-overlay').classList.remove('open');
}
// ============================================
// Questions List Rendering
// ============================================
/**
* Check if there are annotations in document sections
*/
function hasAnnotationsInDocs(docsSection) {
if (!docsSection) return false;
// Check research documents
if (docsSection.research) {
for (const docIndex in docsSection.research) {
const doc = docsSection.research[docIndex];
if (doc.issues?.length > 0 || doc.comment) {
return true;
}
}
}
// Check analytical_hub documents
if (docsSection.analytical_hub) {
for (const docIndex in docsSection.analytical_hub) {
const doc = docsSection.analytical_hub[docIndex];
if (doc.issues?.length > 0 || doc.comment) {
return true;
}
}
}
return false;
}
function renderQuestionsList() {
const container = document.getElementById('questions-list');
const env = getCurrentEnv();
const response = env.currentResponse;
if (!response || !response.answers || response.answers.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">
<span class="material-icons" style="font-size: inherit;">question_answer</span>
</div>
<div class="empty-state-text">Нет данных</div>
<div class="empty-state-subtext">Отправьте запрос к RAG бэкенду</div>
</div>
`;
document.getElementById('questions-count').textContent = '0 вопросов';
return;
}
document.getElementById('questions-count').textContent =
`${response.answers.length} ${pluralize(response.answers.length, 'вопрос', 'вопроса', 'вопросов')}`;
container.innerHTML = response.answers.map((answer, index) => {
const isActive = index === env.currentAnswerIndex;
const annotation = env.annotations[index];
// Check for annotations in body sections
const hasBodyAnnotations = annotation && (
annotation.overall?.comment ||
annotation.body_research?.issues?.length > 0 ||
annotation.body_analytical_hub?.issues?.length > 0
);
// Check for annotations in documents
const hasDocAnnotations = annotation && (
hasAnnotationsInDocs(annotation.docs_from_vectorstore) ||
hasAnnotationsInDocs(annotation.docs_to_llm)
);
const hasAnyAnnotations = hasBodyAnnotations || hasDocAnnotations;
// Get rating indicator
const rating = annotation?.overall?.rating;
let ratingIndicator = '';
if (rating === 'correct') {
ratingIndicator = '<span class="material-icons" style="font-size: 18px; color: #4caf50;">check_circle</span>';
} else if (rating === 'partial') {
ratingIndicator = '<span class="material-icons" style="font-size: 18px; color: #ff9800;">error</span>';
} else if (rating === 'incorrect') {
ratingIndicator = '<span class="material-icons" style="font-size: 18px; color: #f44336;">cancel</span>';
}
// Get annotation bookmark indicator (separate from rating)
const annotationIndicator = hasAnyAnnotations
? '<span class="material-icons color-warning" style="font-size: 18px;">bookmark</span>'
: '';
return `
<div class="card card-clickable question-item ${isActive ? 'active' : ''}"
data-index="${index}"
onclick="selectAnswer(${index})">
<div class="card-content">
<div class="question-item-header">
<div class="text-overline">#${index + 1}</div>
<div style="display: flex; gap: 4px;">
${ratingIndicator}
${annotationIndicator}
</div>
</div>
<div class="question-text">${escapeHtml(answer.question)}</div>
<div class="question-meta">
<span>${formatTime(answer.processing_time_sec)}</span>
</div>
</div>
</div>
`;
}).join('');
}
function pluralize(count, one, few, many) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return one;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few;
return many;
}
function selectAnswer(index) {
const env = getCurrentEnv();
env.currentAnswerIndex = index;
renderQuestionsList();
renderAnswer(index);
}
// ============================================
// Answer Rendering
// ============================================
function renderAnswer(index) {
const env = getCurrentEnv();
const answer = env.currentResponse.answers[index];
if (!answer) return;
const isBackendMode = answer.backend_mode === true;
// Show answer viewer, hide query builder
document.getElementById('query-builder').classList.add('hidden');
document.getElementById('answer-viewer').classList.remove('hidden');
// Render question header
document.getElementById('current-question-number').textContent = index + 1;
document.getElementById('current-question-text').textContent = answer.question;
// Render metadata
document.getElementById('processing-time').textContent = isBackendMode ? 'N/A' : formatTime(answer.processing_time_sec);
document.getElementById('request-id').textContent = env.requestId || '-';
document.getElementById('request-timestamp').textContent =
env.requestTimestamp ? formatTimestamp(env.requestTimestamp) : '-';
// Render answer bodies
renderAnswerBody('body-research-text', answer.body_research);
renderAnswerBody('body-analytical-text', answer.body_analytical_hub);
// Show/hide documents sections based on mode
const docsSection = document.querySelector('.answer-section:has(#docs-tabs)');
if (docsSection) {
docsSection.style.display = isBackendMode ? 'none' : 'block';
}
if (!isBackendMode) {
// Render documents (only in bench mode)
renderDocuments('vectorstore-research-docs', answer.docs_from_vectorstore?.research, 'docs_from_vectorstore', 'research', index);
renderDocuments('vectorstore-analytical-docs', answer.docs_from_vectorstore?.analytical_hub, 'docs_from_vectorstore', 'analytical_hub', index);
renderDocuments('llm-research-docs', answer.docs_to_llm?.research, 'docs_to_llm', 'research', index);
renderDocuments('llm-analytical-docs', answer.docs_to_llm?.analytical_hub, 'docs_to_llm', 'analytical_hub', index);
}
// Load annotations
loadAnnotationsForAnswer(index);
}
function renderAnswerBody(elementId, text) {
const container = document.getElementById(elementId);
if (!text) {
container.innerHTML = '<p class="color-warning">Нет данных</p>';
return;
}
if (isTableText(text)) {
const table = parseTextTable(text);
if (table) {
container.innerHTML = `<div class="table-container">${table}</div>`;
return;
}
}
// Render as plain text with line breaks
container.innerHTML = `<p>${escapeHtml(text).replace(/\n/g, '<br>')}</p>`;
}
function renderDocuments(containerId, docs, section, subsection, answerIndex) {
const container = document.getElementById(containerId);
if (!docs || docs.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-text">Нет документов</div>
</div>
`;
return;
}
container.innerHTML = docs.map((doc, docIndex) => {
const docId = `doc-${section}-${subsection}-${docIndex}`;
let docContent = '';
if (typeof doc === 'string') {
if (isTableText(doc)) {
const table = parseTextTable(doc);
docContent = table || `<pre class="text-table">${escapeHtml(doc)}</pre>`;
} else {
docContent = `<p>${escapeHtml(doc).replace(/\n/g, '<br>')}</p>`;
}
} else {
docContent = `<pre class="text-table">${escapeHtml(JSON.stringify(doc, null, 2))}</pre>`;
}
return `
<div class="expansion-panel" id="${docId}">
<div class="expansion-header" onclick="toggleExpansion('${docId}')">
<span class="expansion-header-title">Документ #${docIndex + 1}</span>
<span class="material-icons expansion-icon">expand_more</span>
</div>
<div class="expansion-content">
<div class="expansion-body">
${docContent}
<div class="annotation-section mt-md">
<h6 class="mb-sm">Пометки</h6>
<div class="annotation-issues mb-md">
<label class="issue-checkbox">
<input type="checkbox" class="checkbox"
data-section="${section}"
data-subsection="${subsection}"
data-doc-index="${docIndex}"
data-issue="factual_error">
<span>Факт. ошибка</span>
</label>
<label class="issue-checkbox">
<input type="checkbox" class="checkbox"
data-section="${section}"
data-subsection="${subsection}"
data-doc-index="${docIndex}"
data-issue="inaccurate_wording">
<span>Неточность формулировки</span>
</label>
<label class="issue-checkbox">
<input type="checkbox" class="checkbox"
data-section="${section}"
data-subsection="${subsection}"
data-doc-index="${docIndex}"
data-issue="insufficient_context">
<span>Недостаточно контекста</span>
</label>
<label class="issue-checkbox">
<input type="checkbox" class="checkbox"
data-section="${section}"
data-subsection="${subsection}"
data-doc-index="${docIndex}"
data-issue="offtopic">
<span>Не по вопросу</span>
</label>
<label class="issue-checkbox">
<input type="checkbox" class="checkbox"
data-section="${section}"
data-subsection="${subsection}"
data-doc-index="${docIndex}"
data-issue="technical_answer">
<span>Технический ответ</span>
</label>
</div>
<div class="form-group">
<label class="form-label">Комментарий</label>
<textarea class="form-textarea" rows="2"
data-section="${section}"
data-subsection="${subsection}"
data-doc-index="${docIndex}"
placeholder="Комментарий к документу..."></textarea>
</div>
</div>
</div>
</div>
</div>
`;
}).join('');
}
function toggleExpansion(id) {
const panel = document.getElementById(id);
panel.classList.toggle('expanded');
}
// ============================================
// Annotation System
// ============================================
function initAnnotationForAnswer(index) {
const env = getCurrentEnv();
if (!env.annotations[index]) {
env.annotations[index] = {
overall: { rating: '', comment: '' },
body_research: { issues: [], comment: '' },
body_analytical_hub: { issues: [], comment: '' },
docs_from_vectorstore: { research: {}, analytical_hub: {} },
docs_to_llm: { research: {}, analytical_hub: {} }
};
}
}
function loadAnnotationsForAnswer(index) {
initAnnotationForAnswer(index);
const env = getCurrentEnv();
const annotation = env.annotations[index];
// Load overall rating
document.getElementById('overall-rating').value = annotation.overall.rating || '';
document.getElementById('overall-comment').value = annotation.overall.comment || '';
// Load body annotations
loadSectionAnnotation('body_research', annotation.body_research);
loadSectionAnnotation('body_analytical_hub', annotation.body_analytical_hub);
// Load document annotations
loadDocumentAnnotations('docs_from_vectorstore', 'research', annotation.docs_from_vectorstore?.research);
loadDocumentAnnotations('docs_from_vectorstore', 'analytical_hub', annotation.docs_from_vectorstore?.analytical_hub);
loadDocumentAnnotations('docs_to_llm', 'research', annotation.docs_to_llm?.research);
loadDocumentAnnotations('docs_to_llm', 'analytical_hub', annotation.docs_to_llm?.analytical_hub);
// Setup event listeners for current answer
setupAnnotationListeners();
}
function loadSectionAnnotation(section, data) {
// Load checkboxes
document.querySelectorAll(`input[data-section="${section}"]`).forEach(checkbox => {
if (checkbox.type === 'checkbox') {
const issue = checkbox.dataset.issue;
checkbox.checked = data.issues.includes(issue);
updateCheckboxStyle(checkbox);
}
});
// Load comment
const textarea = document.querySelector(`textarea[data-section="${section}"]:not([data-doc-index])`);
if (textarea) {
textarea.value = data.comment || '';
}
}
function loadDocumentAnnotations(section, subsection, docs) {
if (!docs) return;
Object.keys(docs).forEach(docIndex => {
const data = docs[docIndex];
// Load checkboxes
document.querySelectorAll(
`input[data-section="${section}"][data-subsection="${subsection}"][data-doc-index="${docIndex}"]`
).forEach(checkbox => {
if (checkbox.type === 'checkbox') {
const issue = checkbox.dataset.issue;
checkbox.checked = data.issues?.includes(issue) || false;
updateCheckboxStyle(checkbox);
}
});
// Load comment
const textarea = document.querySelector(
`textarea[data-section="${section}"][data-subsection="${subsection}"][data-doc-index="${docIndex}"]`
);
if (textarea) {
textarea.value = data.comment || '';
}
});
}
function setupAnnotationListeners() {
const env = getCurrentEnv();
const index = env.currentAnswerIndex;
// Overall rating
document.getElementById('overall-rating').onchange = (e) => {
env.annotations[index].overall.rating = e.target.value;
saveAnnotationsDraft();
};
document.getElementById('overall-comment').oninput = (e) => {
env.annotations[index].overall.comment = e.target.value;
saveAnnotationsDraft();
};
// Section checkboxes and textareas
document.querySelectorAll('input.checkbox, textarea').forEach(element => {
const section = element.dataset.section;
const subsection = element.dataset.subsection;
const docIndex = element.dataset.docIndex;
if (!section) return;
if (element.type === 'checkbox') {
element.onchange = (e) => {
const issue = e.target.dataset.issue;
if (docIndex !== undefined) {
// Document annotation
if (!env.annotations[index][section]) {
env.annotations[index][section] = { research: {}, analytical_hub: {} };
}
if (!env.annotations[index][section][subsection]) {
env.annotations[index][section][subsection] = {};
}
if (!env.annotations[index][section][subsection][docIndex]) {
env.annotations[index][section][subsection][docIndex] = { issues: [], comment: '' };
}
const issues = env.annotations[index][section][subsection][docIndex].issues;
if (e.target.checked) {
if (!issues.includes(issue)) issues.push(issue);
} else {
const idx = issues.indexOf(issue);
if (idx > -1) issues.splice(idx, 1);
}
} else {
// Body annotation
const issues = env.annotations[index][section].issues;
if (e.target.checked) {
if (!issues.includes(issue)) issues.push(issue);
} else {
const idx = issues.indexOf(issue);
if (idx > -1) issues.splice(idx, 1);
}
}
updateCheckboxStyle(e.target);
saveAnnotationsDraft();
};
} else if (element.tagName === 'TEXTAREA') {
element.oninput = (e) => {
if (docIndex !== undefined) {
if (!env.annotations[index][section][subsection][docIndex]) {
env.annotations[index][section][subsection][docIndex] = { issues: [], comment: '' };
}
env.annotations[index][section][subsection][docIndex].comment = e.target.value;
} else if (section !== 'overall') {
env.annotations[index][section].comment = e.target.value;
}
saveAnnotationsDraft();
};
}
});
}
function updateCheckboxStyle(checkbox) {
const label = checkbox.closest('.issue-checkbox');
if (label) {
label.classList.toggle('checked', checkbox.checked);
}
}
function saveAnnotationsDraft() {
// Save annotations and entire environment state
saveEnvironmentData(AppState.currentEnvironment);
}
// ============================================
// Export Functionality
// ============================================
function exportAnalysis() {
const env = getCurrentEnv();
if (!env.currentResponse) {
showToast('Нет данных для экспорта', 'warning');
return;
}
const envSettings = getCurrentEnvSettings();
const envName = AppState.settings.environments[AppState.currentEnvironment].name;
const exportData = {
environment: AppState.currentEnvironment, // Save environment info
api_mode: envSettings.apiMode || 'bench', // Save API mode
request: env.currentRequest,
response: env.currentResponse,
annotations: env.annotations,
settings_snapshot: {
requestId: env.requestId,
systemId: envSettings.systemId,
timestamp: env.requestTimestamp,
environment_name: envName,
api_mode: envSettings.apiMode || 'bench',
},
exported_at: new Date().toISOString(),
};
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
const filename = `brief-bench-analysis-${AppState.currentEnvironment}-${timestamp}.json`;
downloadJSON(exportData, filename);
showToast(`Анализ экспортирован: ${filename}`, 'success');
}
// ============================================
// Initialize on Load
// ============================================
// Initialize app on load
document.addEventListener('DOMContentLoaded', async () => {
// Подключить обработчики (в т.ч. для login/logout)
setupEventListeners();
// Проверить авторизацию
const isAuthenticated = await checkAuth();
if (isAuthenticated) {
// Пользователь авторизован, инициализировать приложение
await initApp();
}
// Если не авторизован, login screen уже показан в checkAuth()
});