1895 lines
59 KiB
JavaScript
1895 lines
59 KiB
JavaScript
/**
|
||
* 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 || '',
|
||
systemPlatform: envSettings.systemPlatform || '',
|
||
systemPlatformUser: envSettings.systemPlatformUser || '',
|
||
platformUserId: envSettings.platformUserId || '',
|
||
platformId: envSettings.platformId || '',
|
||
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;
|
||
}
|
||
|
||
// ============================================
|
||
// API Client
|
||
// ============================================
|
||
|
||
/**
|
||
* Build API URL from current environment settings
|
||
*/
|
||
function buildApiUrl() {
|
||
const envSettings = getCurrentEnvSettings();
|
||
if (envSettings.fullUrl) {
|
||
return envSettings.fullUrl;
|
||
}
|
||
const { host, port, endpoint } = envSettings;
|
||
const portPart = port ? `:${port}` : '';
|
||
const endpointPart = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||
return `https://${host}${portPart}${endpointPart}`;
|
||
}
|
||
|
||
/**
|
||
* Generate Request ID for current environment
|
||
*/
|
||
function generateRequestId() {
|
||
const envSettings = getCurrentEnvSettings();
|
||
const template = envSettings.requestIdTemplate;
|
||
if (template === 'uuid' || !template) {
|
||
return generateUUID();
|
||
}
|
||
// Support simple template substitution (e.g., "req-{timestamp}")
|
||
return template
|
||
.replace('{timestamp}', Date.now())
|
||
.replace('{uuid}', generateUUID());
|
||
}
|
||
|
||
/**
|
||
* Send query to RAG backend using current environment settings (Bench Mode)
|
||
*/
|
||
async function sendQuery(requestBody) {
|
||
const url = buildApiUrl();
|
||
const requestId = generateRequestId();
|
||
const envSettings = getCurrentEnvSettings();
|
||
const env = getCurrentEnv();
|
||
|
||
env.requestId = requestId;
|
||
env.requestTimestamp = new Date().toISOString();
|
||
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
'Request-Id': requestId,
|
||
'System-Id': envSettings.systemId,
|
||
};
|
||
|
||
// Add Bearer token if configured
|
||
if (envSettings.bearerToken) {
|
||
headers['Authorization'] = `Bearer ${envSettings.bearerToken}`;
|
||
}
|
||
|
||
// Add System-Platform header if configured
|
||
if (envSettings.systemPlatform) {
|
||
headers['System-Platform'] = envSettings.systemPlatform;
|
||
}
|
||
|
||
// Add System-Platform-User header if configured
|
||
if (envSettings.systemPlatformUser) {
|
||
headers['System-Platform-User'] = envSettings.systemPlatformUser;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: headers,
|
||
body: JSON.stringify(requestBody),
|
||
signal: AbortSignal.timeout(AppState.settings.requestTimeout),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') {
|
||
throw new Error('Запрос превысил таймаут (30 минут)');
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build Backend API URL
|
||
*/
|
||
function buildBackendApiUrl(endpoint) {
|
||
const envSettings = getCurrentEnvSettings();
|
||
const { host, port, fullUrl } = envSettings;
|
||
|
||
if (fullUrl) {
|
||
// If fullUrl provided, use it as base
|
||
const baseUrl = fullUrl.replace(/\/$/, ''); // Remove trailing slash
|
||
return `${baseUrl}/${endpoint}`;
|
||
}
|
||
|
||
const portPart = port ? `:${port}` : '';
|
||
const endpointPart = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||
return `https://${host}${portPart}${endpointPart}`;
|
||
}
|
||
|
||
/**
|
||
* Build headers for Backend API
|
||
*/
|
||
function buildBackendHeaders() {
|
||
const envSettings = getCurrentEnvSettings();
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
'Platform-User-Id': envSettings.platformUserId,
|
||
'Platform-Id': envSettings.platformId,
|
||
};
|
||
|
||
// Add Bearer token if configured
|
||
if (envSettings.bearerToken) {
|
||
headers['Authorization'] = `Bearer ${envSettings.bearerToken}`;
|
||
}
|
||
|
||
return headers;
|
||
}
|
||
|
||
/**
|
||
* Send single question to Backend /ask endpoint
|
||
*/
|
||
async function sendBackendAsk(question, userMessageId) {
|
||
const envSettings = getCurrentEnvSettings();
|
||
const url = buildBackendApiUrl(envSettings.backendAskEndpoint);
|
||
const headers = buildBackendHeaders();
|
||
|
||
const requestBody = {
|
||
question: question,
|
||
user_message_id: userMessageId,
|
||
user_message_datetime: new Date().toISOString(),
|
||
with_classify: envSettings.withClassify || false,
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: headers,
|
||
body: JSON.stringify(requestBody),
|
||
signal: AbortSignal.timeout(AppState.settings.requestTimeout),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
return data; // Returns AskRagResponse: { answers: [], metadata: {}, question_type: }
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') {
|
||
throw new Error('Запрос превысил таймаут');
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reset session via Backend /context/reset-session endpoint
|
||
*/
|
||
async function sendBackendResetSession() {
|
||
const envSettings = getCurrentEnvSettings();
|
||
const url = buildBackendApiUrl(envSettings.backendResetEndpoint);
|
||
const headers = buildBackendHeaders();
|
||
|
||
const requestBody = {
|
||
user_message_datetime: new Date().toISOString(),
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: headers,
|
||
body: JSON.stringify(requestBody),
|
||
signal: AbortSignal.timeout(AppState.settings.requestTimeout),
|
||
});
|
||
|
||
// Reset returns 204 No Content on success
|
||
if (response.status === 204 || response.ok) {
|
||
return true;
|
||
}
|
||
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') {
|
||
throw new Error('Запрос превысил таймаут');
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send queries in Backend mode (one by one)
|
||
*/
|
||
async function sendBackendQuery(questions) {
|
||
const envSettings = getCurrentEnvSettings();
|
||
const env = getCurrentEnv();
|
||
const resetSession = envSettings.resetSessionMode !== false;
|
||
|
||
env.requestId = generateUUID();
|
||
env.requestTimestamp = new Date().toISOString();
|
||
|
||
const results = [];
|
||
let userMessageId = 1;
|
||
|
||
for (let i = 0; i < questions.length; i++) {
|
||
const question = questions[i];
|
||
|
||
// Update loading message with progress
|
||
updateLoadingMessage(`Вопрос ${i + 1} из ${questions.length}: отправка...`);
|
||
|
||
try {
|
||
// Send question
|
||
const response = await sendBackendAsk(question, userMessageId);
|
||
|
||
// Store result
|
||
results.push({
|
||
question: question,
|
||
answers: response.answers || [],
|
||
question_type: response.question_type || null,
|
||
metadata: response.metadata || {},
|
||
user_message_id: userMessageId,
|
||
});
|
||
|
||
userMessageId++;
|
||
|
||
// Reset session if needed
|
||
if (resetSession && i < questions.length - 1) {
|
||
updateLoadingMessage(`Вопрос ${i + 1} из ${questions.length}: сброс сессии...`);
|
||
await sendBackendResetSession();
|
||
}
|
||
} catch (error) {
|
||
// Store error for this question
|
||
results.push({
|
||
question: question,
|
||
answers: [`Ошибка: ${error.message}`],
|
||
question_type: null,
|
||
metadata: {},
|
||
user_message_id: userMessageId,
|
||
error: true,
|
||
});
|
||
userMessageId++;
|
||
}
|
||
}
|
||
|
||
// Convert to RagResponseBenchList-like format for compatibility with existing UI
|
||
return {
|
||
answers: results.map(r => ({
|
||
question: r.question,
|
||
body_research: r.answers.join('\n\n'),
|
||
body_analytical_hub: r.question_type ? `Тип вопроса: ${r.question_type}` : '',
|
||
processing_time_sec: 0, // Backend doesn't provide this
|
||
docs_from_vectorstore: null,
|
||
docs_to_llm: null,
|
||
metadata: r.metadata,
|
||
backend_mode: true,
|
||
}))
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Update loading message
|
||
*/
|
||
function updateLoadingMessage(message) {
|
||
const loadingMessageEl = document.getElementById('loading-message');
|
||
if (loadingMessageEl) {
|
||
loadingMessageEl.textContent = message;
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// UI Initialization
|
||
// ============================================
|
||
|
||
/**
|
||
* Initialize application
|
||
*/
|
||
function initApp() {
|
||
// Load settings
|
||
AppState.settings = loadSettings();
|
||
AppState.currentEnvironment = AppState.settings.activeEnvironment || '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() {
|
||
// 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);
|
||
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;
|
||
saveSettings(AppState.settings);
|
||
|
||
// 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');
|
||
}
|
||
|
||
function saveSettingsHandler() {
|
||
const settings = readSettingsFromDialog();
|
||
saveSettings(settings);
|
||
closeSettingsDialog();
|
||
showToast('Настройки сохранены', 'success');
|
||
}
|
||
|
||
function resetSettings() {
|
||
if (confirm('Сбросить все настройки к значениям по умолчанию?')) {
|
||
AppState.settings = { ...defaultSettings };
|
||
saveSettings(AppState.settings);
|
||
populateSettingsDialog();
|
||
showToast('Настройки сброшены', 'info');
|
||
}
|
||
}
|
||
|
||
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 localStorage
|
||
saveSettings(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';
|
||
|
||
let response;
|
||
let questions;
|
||
|
||
if (apiMode === 'backend') {
|
||
// Backend mode: extract questions as string array
|
||
questions = extractQuestions();
|
||
env.currentRequest = questions.map(q => ({ body: q, backend_mode: true }));
|
||
|
||
// Show loading
|
||
showLoading('Отправка вопросов к Backend API...');
|
||
|
||
// Send queries one by one
|
||
response = await sendBackendQuery(questions);
|
||
} else {
|
||
// Bench mode: use original logic
|
||
const requestBody = buildRequestBody();
|
||
env.currentRequest = requestBody;
|
||
|
||
// Show loading
|
||
showLoading('Отправка запроса к RAG бэкенду...');
|
||
|
||
// Send query
|
||
response = await sendQuery(requestBody);
|
||
}
|
||
|
||
// Hide loading
|
||
hideLoading();
|
||
|
||
// Validate response
|
||
if (!response.answers || !Array.isArray(response.answers)) {
|
||
throw new Error('Некорректный формат ответа: отсутствует поле "answers"');
|
||
}
|
||
|
||
// Save response
|
||
env.currentResponse = response;
|
||
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}] Получено ${response.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;
|
||
saveSettings(AppState.settings);
|
||
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
|
||
// ============================================
|
||
|
||
document.addEventListener('DOMContentLoaded', initApp);
|