From cf86a9378c3fb322733a3fdcaa4603a98e563a53 Mon Sep 17 00:00:00 2001 From: itqop Date: Wed, 17 Dec 2025 18:29:53 +0300 Subject: [PATCH] v2 --- app/main.py | 13 +- static/api-client.js | 246 ++++++ static/app.js | 1671 +++++++++++++++++++++++++++++++++++++ static/app.js.bak | 1894 ++++++++++++++++++++++++++++++++++++++++++ static/index.html | 451 ++++++++++ static/settings.js | 70 ++ static/styles.css | 1166 ++++++++++++++++++++++++++ 7 files changed, 5508 insertions(+), 3 deletions(-) create mode 100644 static/api-client.js create mode 100644 static/app.js create mode 100644 static/app.js.bak create mode 100644 static/index.html create mode 100644 static/settings.js create mode 100644 static/styles.css diff --git a/app/main.py b/app/main.py index 4f1adda..7b64477 100644 --- a/app/main.py +++ b/app/main.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse from app.api.v1 import auth, settings as settings_router, query, analysis from app.config import settings @@ -30,13 +31,19 @@ app.include_router(query.router, prefix="/api/v1") app.include_router(analysis.router, prefix="/api/v1") # Serve static files (frontend) -# app.mount("/static", StaticFiles(directory="static"), name="static") +app.mount("/static", StaticFiles(directory="static"), name="static") + + +@app.get("/app") +async def serve_frontend(): + """Serve the main frontend application.""" + return FileResponse("static/index.html") @app.get("/") async def root(): - """Root endpoint.""" - return {"message": "Brief Bench API", "version": "1.0.0"} + """Root endpoint - redirect to app.""" + return FileResponse("static/index.html") @app.get("/health") diff --git a/static/api-client.js b/static/api-client.js new file mode 100644 index 0000000..5fed10d --- /dev/null +++ b/static/api-client.js @@ -0,0 +1,246 @@ +/** + * Brief Bench API Client + * Взаимодействие с FastAPI backend + */ +class BriefBenchAPI { + constructor() { + this.baseURL = '/api/v1' + } + + // ============================================ + // Internal Helpers + // ============================================ + + _getToken() { + return localStorage.getItem('access_token') + } + + _setToken(token) { + localStorage.setItem('access_token', token) + } + + _clearToken() { + localStorage.removeItem('access_token') + } + + _getHeaders(includeAuth = true) { + const headers = { + 'Content-Type': 'application/json' + } + + if (includeAuth) { + const token = this._getToken() + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + } + + return headers + } + + async _handleResponse(response) { + // Handle 401 Unauthorized + if (response.status === 401) { + this._clearToken() + throw new Error('Сессия истекла. Пожалуйста, войдите снова.') + } + + // Handle 502 Bad Gateway (RAG backend error) + if (response.status === 502) { + throw new Error('RAG backend недоступен или вернул ошибку') + } + + // Handle other errors + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`) + } + + // Handle 204 No Content + if (response.status === 204) { + return null + } + + return await response.json() + } + + async _request(endpoint, options = {}) { + const url = `${this.baseURL}${endpoint}` + + try { + const response = await fetch(url, options) + return await this._handleResponse(response) + } catch (error) { + console.error(`API request failed: ${endpoint}`, error) + throw error + } + } + + // ============================================ + // Auth API + // ============================================ + + /** + * Авторизация с 8-значным логином + * @param {string} login - 8-значный логин + * @returns {Promise<{access_token: string, user: object}>} + */ + async login(login) { + const response = await this._request(`/auth/login?login=${login}`, { + method: 'POST', + headers: this._getHeaders(false) + }) + + // Сохранить токен + this._setToken(response.access_token) + + return response + } + + /** + * Выход (очистка токена) + */ + logout() { + this._clearToken() + window.location.reload() + } + + /** + * Проверка авторизации + * @returns {boolean} + */ + isAuthenticated() { + return !!this._getToken() + } + + // ============================================ + // Settings API + // ============================================ + + /** + * Получить настройки пользователя для всех окружений + * @returns {Promise<{user_id: string, settings: object, updated_at: string}>} + */ + async getSettings() { + return await this._request('/settings', { + method: 'GET', + headers: this._getHeaders() + }) + } + + /** + * Обновить настройки пользователя + * @param {object} settings - Объект с настройками для окружений + * @returns {Promise<{user_id: string, settings: object, updated_at: string}>} + */ + async updateSettings(settings) { + return await this._request('/settings', { + method: 'PUT', + headers: this._getHeaders(), + body: JSON.stringify({ settings }) + }) + } + + // ============================================ + // Query API + // ============================================ + + /** + * Отправить batch запрос (Bench mode) + * @param {string} environment - Окружение (ift/psi/prod) + * @param {Array<{body: string, with_docs: boolean}>} questions - Массив вопросов + * @returns {Promise<{request_id: string, timestamp: string, environment: string, response: object}>} + */ + async benchQuery(environment, questions) { + return await this._request('/query/bench', { + method: 'POST', + headers: this._getHeaders(), + body: JSON.stringify({ + environment, + questions + }) + }) + } + + /** + * Отправить последовательные запросы (Backend mode) + * @param {string} environment - Окружение (ift/psi/prod) + * @param {Array<{body: string, with_docs: boolean}>} questions - Массив вопросов + * @param {boolean} resetSession - Сбрасывать ли сессию после каждого вопроса + * @returns {Promise<{request_id: string, timestamp: string, environment: string, response: object}>} + */ + async backendQuery(environment, questions, resetSession = true) { + return await this._request('/query/backend', { + method: 'POST', + headers: this._getHeaders(), + body: JSON.stringify({ + environment, + questions, + reset_session: resetSession + }) + }) + } + + // ============================================ + // Analysis Sessions API + // ============================================ + + /** + * Сохранить сессию анализа + * @param {object} sessionData - Данные сессии + * @returns {Promise} + */ + async saveSession(sessionData) { + return await this._request('/analysis/sessions', { + method: 'POST', + headers: this._getHeaders(), + body: JSON.stringify(sessionData) + }) + } + + /** + * Получить список сессий + * @param {string|null} environment - Фильтр по окружению (опционально) + * @param {number} limit - Лимит результатов + * @param {number} offset - Смещение для пагинации + * @returns {Promise<{sessions: Array, total: number}>} + */ + async getSessions(environment = null, limit = 50, offset = 0) { + const params = new URLSearchParams({ limit, offset }) + if (environment) { + params.append('environment', environment) + } + + return await this._request(`/analysis/sessions?${params}`, { + method: 'GET', + headers: this._getHeaders() + }) + } + + /** + * Получить конкретную сессию + * @param {string} sessionId - ID сессии + * @returns {Promise} + */ + async getSession(sessionId) { + return await this._request(`/analysis/sessions/${sessionId}`, { + method: 'GET', + headers: this._getHeaders() + }) + } + + /** + * Удалить сессию + * @param {string} sessionId - ID сессии + * @returns {Promise} + */ + async deleteSession(sessionId) { + return await this._request(`/analysis/sessions/${sessionId}`, { + method: 'DELETE', + headers: this._getHeaders() + }) + } +} + +// Export API client instance +const api = new BriefBenchAPI() diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..7d630ea --- /dev/null +++ b/static/app.js @@ -0,0 +1,1671 @@ +/** + * 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 => `${escapeHtml(cell)}`).join(''); + thead = `${headerCells}`; + + const bodyRows = rows.slice(2).map(row => { + const cells = row.map(cell => `${escapeHtml(cell)}`).join(''); + return `${cells}`; + }).join(''); + tbody = `${bodyRows}`; + } else { + // No header, all rows are data + const bodyRows = rows.map(row => { + const cells = row.map(cell => `${escapeHtml(cell)}`).join(''); + return `${cells}`; + }).join(''); + tbody = `${bodyRows}`; + } + + return `${thead}${tbody}
`; +} + +/** + * 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; +} + +// ============================================ +// 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 = ` +
+
+ question_answer +
+
Нет данных
+
Отправьте запрос к RAG бэкенду
+
+ `; + 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 = 'check_circle'; + } else if (rating === 'partial') { + ratingIndicator = 'error'; + } else if (rating === 'incorrect') { + ratingIndicator = 'cancel'; + } + + // Get annotation bookmark indicator (separate from rating) + const annotationIndicator = hasAnyAnnotations + ? 'bookmark' + : ''; + + return ` +
+
+
+
#${index + 1}
+
+ ${ratingIndicator} + ${annotationIndicator} +
+
+
${escapeHtml(answer.question)}
+
+ ${formatTime(answer.processing_time_sec)} +
+
+
+ `; + }).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 = '

Нет данных

'; + return; + } + + if (isTableText(text)) { + const table = parseTextTable(text); + if (table) { + container.innerHTML = `
${table}
`; + return; + } + } + + // Render as plain text with line breaks + container.innerHTML = `

${escapeHtml(text).replace(/\n/g, '
')}

`; +} + +function renderDocuments(containerId, docs, section, subsection, answerIndex) { + const container = document.getElementById(containerId); + + if (!docs || docs.length === 0) { + container.innerHTML = ` +
+
Нет документов
+
+ `; + 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 || `
${escapeHtml(doc)}
`; + } else { + docContent = `

${escapeHtml(doc).replace(/\n/g, '
')}

`; + } + } else { + docContent = `
${escapeHtml(JSON.stringify(doc, null, 2))}
`; + } + + return ` +
+
+ Документ #${docIndex + 1} + expand_more +
+
+
+ ${docContent} + +
+
Пометки
+
+ + + + + +
+
+ + +
+
+
+
+
+ `; + }).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() +}); diff --git a/static/app.js.bak b/static/app.js.bak new file mode 100644 index 0000000..5d32def --- /dev/null +++ b/static/app.js.bak @@ -0,0 +1,1894 @@ +/** + * 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 => `${escapeHtml(cell)}`).join(''); + thead = `${headerCells}`; + + const bodyRows = rows.slice(2).map(row => { + const cells = row.map(cell => `${escapeHtml(cell)}`).join(''); + return `${cells}`; + }).join(''); + tbody = `${bodyRows}`; + } else { + // No header, all rows are data + const bodyRows = rows.map(row => { + const cells = row.map(cell => `${escapeHtml(cell)}`).join(''); + return `${cells}`; + }).join(''); + tbody = `${bodyRows}`; + } + + return `${thead}${tbody}
`; +} + +/** + * 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 = ` +
+
+ question_answer +
+
Нет данных
+
Отправьте запрос к RAG бэкенду
+
+ `; + 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 = 'check_circle'; + } else if (rating === 'partial') { + ratingIndicator = 'error'; + } else if (rating === 'incorrect') { + ratingIndicator = 'cancel'; + } + + // Get annotation bookmark indicator (separate from rating) + const annotationIndicator = hasAnyAnnotations + ? 'bookmark' + : ''; + + return ` +
+
+
+
#${index + 1}
+
+ ${ratingIndicator} + ${annotationIndicator} +
+
+
${escapeHtml(answer.question)}
+
+ ${formatTime(answer.processing_time_sec)} +
+
+
+ `; + }).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 = '

Нет данных

'; + return; + } + + if (isTableText(text)) { + const table = parseTextTable(text); + if (table) { + container.innerHTML = `
${table}
`; + return; + } + } + + // Render as plain text with line breaks + container.innerHTML = `

${escapeHtml(text).replace(/\n/g, '
')}

`; +} + +function renderDocuments(containerId, docs, section, subsection, answerIndex) { + const container = document.getElementById(containerId); + + if (!docs || docs.length === 0) { + container.innerHTML = ` +
+
Нет документов
+
+ `; + 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 || `
${escapeHtml(doc)}
`; + } else { + docContent = `

${escapeHtml(doc).replace(/\n/g, '
')}

`; + } + } else { + docContent = `
${escapeHtml(JSON.stringify(doc, null, 2))}
`; + } + + return ` +
+
+ Документ #${docIndex + 1} + expand_more +
+
+
+ ${docContent} + +
+
Пометки
+
+ + + + + +
+
+ + +
+
+
+
+
+ `; + }).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); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..0a07127 --- /dev/null +++ b/static/index.html @@ -0,0 +1,451 @@ + + + + + + + Brief Bench - RAG Testing Interface + + + + + + + + + + + + + + + + +
+ +
+
+ + Brief Bench +
+
+ + + + + +
+
+ + +
+ + + +
+ + +
+ + + + +
+ + + + + +
+
+
+ + +
+
+
+
Настройки
+ +
+
+ +
+ + +
+ +
+ + +
+
+ + +
Bench - отправка массива вопросов; Backend - вопросы по одному
+
+ +
+ + +
Токен для авторизации запросов к RAG API
+
+ +
+ + +
Заголовок System-Platform для запросов
+
+ +
+ + +
Заголовок System-Platform-User для запросов
+
+ + + +
+
+
+ + + + +
+
+
+ + +
+
+
Отправка запроса...
+
Это может занять до 30 минут
+
+ + + + + + + diff --git a/static/settings.js b/static/settings.js new file mode 100644 index 0000000..2c81fde --- /dev/null +++ b/static/settings.js @@ -0,0 +1,70 @@ +// Default settings for Brief Bench (FastAPI Version) +// User-editable fields only - server configuration managed by FastAPI backend + +const defaultSettings = { + // Active environment + activeEnvironment: 'ift', // 'ift', 'psi', or 'prod' + + // Environment-specific settings + environments: { + ift: { + name: 'ИФТ', + apiMode: 'bench', // 'bench' or 'backend' + + // Optional headers for RAG backend + bearerToken: '', // Bearer token for authorization (optional) + systemPlatform: '', // System-Platform header (optional) + systemPlatformUser: '', // System-Platform-User header (optional) + + // Backend mode settings + platformUserId: '', + platformId: '', + withClassify: false, + resetSessionMode: true // Reset session after each question + }, + psi: { + name: 'ПСИ', + apiMode: 'bench', // 'bench' or 'backend' + + // Optional headers for RAG backend + bearerToken: '', + systemPlatform: '', + systemPlatformUser: '', + + // Backend mode settings + platformUserId: '', + platformId: '', + withClassify: false, + resetSessionMode: true + }, + prod: { + name: 'ПРОМ', + apiMode: 'bench', // 'bench' or 'backend' + + // Optional headers for RAG backend + bearerToken: '', + systemPlatform: '', + systemPlatformUser: '', + + // Backend mode settings + platformUserId: '', + platformId: '', + withClassify: false, + resetSessionMode: true + } + }, + + // UI settings + theme: 'light', + autoSaveDrafts: true, + requestTimeout: 1800000, // 30 minutes in milliseconds + + // Query settings + defaultWithDocs: true, + defaultQueryMode: 'questions' // 'questions' or 'raw-json' +}; + +// Export for use in app.js +if (typeof module !== 'undefined' && module.exports) { + module.exports = defaultSettings; +} diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..97363b4 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,1166 @@ +/* ============================================ + Brief Bench - Material Design CSS System + ============================================ */ + +/* CSS Variables - Material Design Tokens */ +:root { + /* Colors - Material Design 3 inspired */ + --md-primary: #1976d2; + --md-primary-dark: #115293; + --md-primary-light: #4791db; + --md-secondary: #26a69a; + --md-error: #d32f2f; + --md-warning: #f57c00; + --md-success: #388e3c; + --md-info: #0288d1; + + /* Neutrals */ + --md-surface: #ffffff; + --md-surface-variant: #f5f5f5; + --md-background: #fafafa; + --md-on-surface: rgba(0, 0, 0, 0.87); + --md-on-surface-variant: rgba(0, 0, 0, 0.60); + --md-on-surface-disabled: rgba(0, 0, 0, 0.38); + --md-outline: rgba(0, 0, 0, 0.12); + --md-divider: rgba(0, 0, 0, 0.12); + + /* Shadows */ + --md-shadow-1: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); + --md-shadow-2: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); + --md-shadow-3: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23); + --md-shadow-4: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); + --md-shadow-5: 0 19px 38px rgba(0, 0, 0, 0.30), 0 15px 12px rgba(0, 0, 0, 0.22); + + /* Typography */ + --md-font-family: 'Roboto', 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif; + --md-font-mono: 'Roboto Mono', 'Consolas', 'Monaco', monospace; + + /* Spacing */ + --md-spacing-xs: 4px; + --md-spacing-sm: 8px; + --md-spacing-md: 16px; + --md-spacing-lg: 24px; + --md-spacing-xl: 32px; + --md-spacing-xxl: 48px; + + /* Borders & Radius */ + --md-radius-sm: 4px; + --md-radius-md: 8px; + --md-radius-lg: 12px; + --md-radius-xl: 16px; + + /* Transitions */ + --md-transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --md-transition-medium: 250ms cubic-bezier(0.4, 0, 0.2, 1); + --md-transition-slow: 375ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Layout */ + --app-bar-height: 64px; + --drawer-width: 320px; +} + +/* ============================================ + Reset & Base Styles + ============================================ */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + height: 100%; + font-size: 16px; +} + +body { + font-family: var(--md-font-family); + color: var(--md-on-surface); + background-color: var(--md-background); + line-height: 1.5; + height: 100%; + overflow: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ============================================ + Typography + ============================================ */ + +h1, h2, h3, h4, h5, h6 { + font-weight: 400; + line-height: 1.2; + margin-bottom: var(--md-spacing-md); +} + +h1 { font-size: 2.5rem; font-weight: 300; } +h2 { font-size: 2rem; font-weight: 400; } +h3 { font-size: 1.75rem; font-weight: 400; } +h4 { font-size: 1.5rem; font-weight: 500; } +h5 { font-size: 1.25rem; font-weight: 500; } +h6 { font-size: 1rem; font-weight: 500; } + +p { + margin-bottom: var(--md-spacing-md); +} + +.text-caption { + font-size: 0.75rem; + color: var(--md-on-surface-variant); +} + +.text-overline { + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--md-on-surface-variant); +} + +.text-mono { + font-family: var(--md-font-mono); +} + +/* ============================================ + Layout + ============================================ */ + +#app { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +/* ============================================ + Environment Tabs + ============================================ */ + +.environment-tabs { + display: flex; + background-color: var(--md-surface); + border-bottom: 2px solid var(--md-divider); + box-shadow: var(--md-shadow-1); + z-index: 90; +} + +.env-tab { + flex: 1; + padding: 12px 24px; + border: none; + background: none; + cursor: pointer; + font-family: var(--md-font-family); + font-size: 0.95rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--md-on-surface-variant); + position: relative; + transition: all var(--md-transition-fast); + border-bottom: 3px solid transparent; + margin-bottom: -2px; +} + +.env-tab:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +.env-tab.active { + color: var(--md-primary); + border-bottom-color: var(--md-primary); + background-color: rgba(25, 118, 210, 0.08); +} + +.app-content { + display: flex; + flex: 1; + overflow: hidden; + position: relative; +} + +/* ============================================ + AppBar + ============================================ */ + +.app-bar { + height: var(--app-bar-height); + background-color: var(--md-primary); + color: white; + display: flex; + align-items: center; + padding: 0 var(--md-spacing-md); + box-shadow: var(--md-shadow-2); + position: relative; + z-index: 100; +} + +.app-bar-title { + font-size: 1.25rem; + font-weight: 500; + flex: 1; + display: flex; + align-items: center; + gap: var(--md-spacing-md); +} + +.app-bar-actions { + display: flex; + gap: var(--md-spacing-sm); + align-items: center; +} + +/* ============================================ + Drawer / Sidebar + ============================================ */ + +.drawer { + width: var(--drawer-width); + background-color: var(--md-surface); + border-right: 1px solid var(--md-divider); + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + z-index: 50; + transition: transform var(--md-transition-medium), width var(--md-transition-medium); +} + +.drawer.collapsed { + transform: translateX(-100%); + width: 0; + border-right: none; +} + +.drawer-header { + padding: var(--md-spacing-md); + border-bottom: 1px solid var(--md-divider); + background-color: var(--md-surface-variant); + display: flex; + align-items: center; + gap: var(--md-spacing-sm); +} + +.drawer-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +/* Custom scrollbar */ +.drawer-content::-webkit-scrollbar { + width: 8px; +} + +.drawer-content::-webkit-scrollbar-track { + background: var(--md-surface-variant); +} + +.drawer-content::-webkit-scrollbar-thumb { + background: var(--md-outline); + border-radius: 4px; +} + +.drawer-content::-webkit-scrollbar-thumb:hover { + background: var(--md-on-surface-variant); +} + +/* ============================================ + Main Area + ============================================ */ + +.main-area { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + background-color: var(--md-background); + padding: var(--md-spacing-lg); +} + +/* ============================================ + Cards + ============================================ */ + +.card { + background-color: var(--md-surface); + border-radius: var(--md-radius-md); + box-shadow: var(--md-shadow-1); + overflow: hidden; + transition: box-shadow var(--md-transition-fast); +} + +.card:hover { + box-shadow: var(--md-shadow-2); +} + +.card-header { + padding: var(--md-spacing-md); + border-bottom: 1px solid var(--md-divider); + display: flex; + align-items: center; + justify-content: space-between; +} + +.card-title { + font-size: 1.25rem; + font-weight: 500; + margin: 0; +} + +.card-content { + padding: var(--md-spacing-md); +} + +.card-actions { + padding: var(--md-spacing-sm) var(--md-spacing-md); + border-top: 1px solid var(--md-divider); + display: flex; + gap: var(--md-spacing-sm); + justify-content: flex-end; +} + +.card-clickable { + cursor: pointer; + transition: all var(--md-transition-fast); +} + +.card-clickable:hover { + background-color: var(--md-surface-variant); + transform: translateY(-2px); + box-shadow: var(--md-shadow-3); +} + +.card-clickable.active { + border-left: 4px solid var(--md-primary); + background-color: rgba(25, 118, 210, 0.08); +} + +/* ============================================ + Buttons + ============================================ */ + +.btn { + font-family: var(--md-font-family); + font-size: 0.875rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 8px 16px; + border: none; + border-radius: var(--md-radius-sm); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--md-spacing-sm); + transition: all var(--md-transition-fast); + position: relative; + overflow: hidden; + outline: none; +} + +.btn:disabled { + opacity: 0.38; + cursor: not-allowed; + pointer-events: none; +} + +/* Button variants */ +.btn-filled { + background-color: var(--md-primary); + color: white; + box-shadow: var(--md-shadow-1); +} + +.btn-filled:hover:not(:disabled) { + background-color: var(--md-primary-dark); + box-shadow: var(--md-shadow-2); +} + +.btn-filled:active:not(:disabled) { + box-shadow: var(--md-shadow-3); +} + +.btn-outlined { + background-color: transparent; + color: var(--md-primary); + border: 1px solid var(--md-primary); +} + +.btn-outlined:hover:not(:disabled) { + background-color: rgba(25, 118, 210, 0.08); +} + +.btn-text { + background-color: transparent; + color: var(--md-primary); +} + +.btn-text:hover:not(:disabled) { + background-color: rgba(25, 118, 210, 0.08); +} + +.btn-icon { + width: 40px; + height: 40px; + padding: 8px; + border-radius: 50%; + background-color: transparent; + border: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + color: inherit; + transition: background-color var(--md-transition-fast); +} + +.btn-icon:hover:not(:disabled) { + background-color: rgba(0, 0, 0, 0.08); +} + +.app-bar .btn-icon { + color: white; +} + +.app-bar .btn-icon:hover:not(:disabled) { + background-color: rgba(255, 255, 255, 0.15); +} + +/* ============================================ + Form Controls + ============================================ */ + +.form-group { + margin-bottom: var(--md-spacing-md); +} + +.form-label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: var(--md-spacing-sm); + color: var(--md-on-surface-variant); +} + +.form-input, +.form-textarea, +.form-select { + font-family: var(--md-font-family); + font-size: 1rem; + width: 100%; + padding: 12px 16px; + border: 1px solid var(--md-outline); + border-radius: var(--md-radius-sm); + background-color: var(--md-surface); + color: var(--md-on-surface); + transition: all var(--md-transition-fast); + outline: none; +} + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + border-color: var(--md-primary); + box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.2); +} + +.form-input.error, +.form-textarea.error { + border-color: var(--md-error); +} + +.form-input.error:focus, +.form-textarea.error:focus { + box-shadow: 0 0 0 2px rgba(211, 47, 47, 0.2); +} + +.form-textarea { + min-height: 100px; + resize: vertical; + font-family: var(--md-font-mono); +} + +.form-helper-text { + font-size: 0.75rem; + margin-top: var(--md-spacing-xs); + color: var(--md-on-surface-variant); +} + +.form-helper-text.error { + color: var(--md-error); +} + +.checkbox-wrapper { + display: flex; + align-items: center; + gap: var(--md-spacing-sm); + cursor: pointer; + -webkit-user-select: none; + user-select: none; +} + +.checkbox { + width: 18px; + height: 18px; + cursor: pointer; +} + +/* ============================================ + Tabs + ============================================ */ + +.tabs { + display: flex; + border-bottom: 2px solid var(--md-divider); + background-color: var(--md-surface); + position: sticky; + top: 0; + z-index: 10; +} + +.tab { + flex: 1; + padding: 12px 24px; + border: none; + background: none; + cursor: pointer; + font-family: var(--md-font-family); + font-size: 0.875rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--md-on-surface-variant); + position: relative; + transition: all var(--md-transition-fast); + border-bottom: 2px solid transparent; + margin-bottom: -2px; +} + +.tab:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +.tab.active { + color: var(--md-primary); + border-bottom-color: var(--md-primary); +} + +.tab-content { + display: none; + padding: var(--md-spacing-md); + animation: fadeIn var(--md-transition-fast); +} + +.tab-content.active { + display: block; +} + +/* ============================================ + Dialog / Modal + ============================================ */ + +.dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn var(--md-transition-medium); +} + +.dialog-overlay.open { + display: flex; +} + +.dialog { + background-color: var(--md-surface); + border-radius: var(--md-radius-lg); + box-shadow: var(--md-shadow-5); + min-width: 400px; + max-width: 90vw; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: slideUp var(--md-transition-medium); +} + +.dialog-header { + padding: var(--md-spacing-lg); + border-bottom: 1px solid var(--md-divider); + display: flex; + align-items: center; + justify-content: space-between; +} + +.dialog-title { + font-size: 1.5rem; + font-weight: 400; + margin: 0; +} + +.dialog-content { + padding: var(--md-spacing-lg); + overflow-y: auto; + flex: 1; +} + +.dialog-actions { + padding: var(--md-spacing-md) var(--md-spacing-lg); + border-top: 1px solid var(--md-divider); + display: flex; + gap: var(--md-spacing-sm); + justify-content: flex-end; +} + +/* ============================================ + Expansion Panel / Accordion + ============================================ */ + +.expansion-panel { + border: 1px solid var(--md-divider); + border-radius: var(--md-radius-sm); + margin-bottom: var(--md-spacing-sm); + overflow: hidden; + transition: all var(--md-transition-fast); +} + +.expansion-panel:hover { + box-shadow: var(--md-shadow-1); +} + +.expansion-header { + padding: var(--md-spacing-md); + background-color: var(--md-surface-variant); + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + -webkit-user-select: none; + user-select: none; + transition: background-color var(--md-transition-fast); +} + +.expansion-header:hover { + background-color: rgba(0, 0, 0, 0.08); +} + +.expansion-header-title { + font-weight: 500; +} + +.expansion-icon { + transition: transform var(--md-transition-fast); + display: inline-flex; +} + +.expansion-panel.expanded .expansion-icon { + transform: rotate(180deg); +} + +.expansion-content { + max-height: 0; + overflow: hidden; + transition: max-height var(--md-transition-medium); +} + +.expansion-panel.expanded .expansion-content { + max-height: 2000px; +} + +.expansion-body { + padding: var(--md-spacing-md); + border-top: 1px solid var(--md-divider); +} + +/* ============================================ + Loading / Spinner + ============================================ */ + +.spinner { + width: 40px; + height: 40px; + border: 4px solid var(--md-outline); + border-top-color: var(--md-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.spinner-small { + width: 20px; + height: 20px; + border-width: 2px; +} + +.loading-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: none; + align-items: center; + justify-content: center; + z-index: 2000; + flex-direction: column; + gap: var(--md-spacing-lg); +} + +.loading-overlay.open { + display: flex; +} + +.loading-message { + color: white; + font-size: 1.25rem; + text-align: center; +} + +.loading-submessage { + color: rgba(255, 255, 255, 0.7); + font-size: 0.875rem; +} + +/* ============================================ + Chips + ============================================ */ + +.chip { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 16px; + font-size: 0.875rem; + background-color: var(--md-surface-variant); + color: var(--md-on-surface); + margin: 2px; + gap: var(--md-spacing-xs); +} + +.chip-error { + background-color: rgba(211, 47, 47, 0.1); + color: var(--md-error); +} + +.chip-warning { + background-color: rgba(245, 124, 0, 0.1); + color: var(--md-warning); +} + +.chip-success { + background-color: rgba(56, 142, 60, 0.1); + color: var(--md-success); +} + +/* ============================================ + Table Rendering + ============================================ */ + +.table-container { + overflow-x: auto; + margin: var(--md-spacing-md) 0; + border-radius: var(--md-radius-sm); + background-color: var(--md-surface-variant); +} + +.text-table { + font-family: var(--md-font-mono); + font-size: 0.875rem; + padding: var(--md-spacing-md); + white-space: pre; + overflow-x: auto; + background-color: var(--md-surface-variant); + border-radius: var(--md-radius-sm); + border: 1px solid var(--md-divider); +} + +table.rendered-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +table.rendered-table th, +table.rendered-table td { + padding: 12px; + text-align: left; + border: 1px solid var(--md-divider); +} + +table.rendered-table th { + background-color: var(--md-surface-variant); + font-weight: 500; + color: var(--md-on-surface-variant); +} + +table.rendered-table tbody tr:nth-child(even) { + background-color: rgba(0, 0, 0, 0.02); +} + +table.rendered-table tbody tr:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +/* ============================================ + List Items + ============================================ */ + +.list-item { + padding: var(--md-spacing-md); + border-bottom: 1px solid var(--md-divider); + cursor: pointer; + transition: background-color var(--md-transition-fast); +} + +.list-item:hover { + background-color: var(--md-surface-variant); +} + +.list-item:last-child { + border-bottom: none; +} + +.list-item-primary { + font-weight: 500; + margin-bottom: var(--md-spacing-xs); +} + +.list-item-secondary { + font-size: 0.875rem; + color: var(--md-on-surface-variant); +} + +/* ============================================ + Utility Classes + ============================================ */ + +.hidden { + display: none !important; +} + +.flex { + display: flex; +} + +.flex-column { + flex-direction: column; +} + +.flex-center { + align-items: center; + justify-content: center; +} + +.gap-sm { gap: var(--md-spacing-sm); } +.gap-md { gap: var(--md-spacing-md); } +.gap-lg { gap: var(--md-spacing-lg); } + +.mt-sm { margin-top: var(--md-spacing-sm); } +.mt-md { margin-top: var(--md-spacing-md); } +.mt-lg { margin-top: var(--md-spacing-lg); } + +.mb-sm { margin-bottom: var(--md-spacing-sm); } +.mb-md { margin-bottom: var(--md-spacing-md); } +.mb-lg { margin-bottom: var(--md-spacing-lg); } + +.p-sm { padding: var(--md-spacing-sm); } +.p-md { padding: var(--md-spacing-md); } +.p-lg { padding: var(--md-spacing-lg); } + +.text-center { text-align: center; } +.text-right { text-align: right; } + +.color-error { color: var(--md-error); } +.color-warning { color: var(--md-warning); } +.color-success { color: var(--md-success); } +.color-primary { color: var(--md-primary); } + +/* ============================================ + Animations + ============================================ */ + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + transform: translateY(50px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ============================================ + Responsive Design + ============================================ */ + +@media (max-width: 768px) { + :root { + --drawer-width: 280px; + --app-bar-height: 56px; + } + + .drawer { + position: absolute; + left: 0; + height: 100%; + z-index: 150; + transition: left var(--md-transition-medium); + box-shadow: var(--md-shadow-3); + transform: none; + } + + .drawer.collapsed { + left: -100%; + transform: none; + } + + .dialog { + min-width: 300px; + } + + .main-area { + padding: var(--md-spacing-md); + } +} + +/* ============================================ + Component-specific Styles + ============================================ */ + +/* Question List Item */ +.question-item { + margin-bottom: var(--md-spacing-sm); +} + +.question-item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--md-spacing-xs); +} + +.question-text { + font-weight: 500; + flex: 1; + word-break: break-word; +} + +.question-meta { + display: flex; + gap: var(--md-spacing-sm); + font-size: 0.75rem; + color: var(--md-on-surface-variant); +} + +/* Annotation Controls */ +.annotation-section { + margin-top: var(--md-spacing-md); + padding: var(--md-spacing-md); + background-color: var(--md-surface-variant); + border-radius: var(--md-radius-sm); +} + +.annotation-issues { + display: flex; + flex-wrap: wrap; + gap: var(--md-spacing-sm); + margin-bottom: var(--md-spacing-md); +} + +.issue-checkbox { + display: flex; + align-items: center; + gap: var(--md-spacing-xs); + padding: 6px 12px; + background-color: var(--md-surface); + border: 1px solid var(--md-outline); + border-radius: var(--md-radius-sm); + cursor: pointer; + transition: all var(--md-transition-fast); + -webkit-user-select: none; + user-select: none; +} + +.issue-checkbox:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +.issue-checkbox.checked { + background-color: rgba(211, 47, 47, 0.1); + border-color: var(--md-error); + color: var(--md-error); +} + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--md-spacing-xxl); + text-align: center; + color: var(--md-on-surface-variant); +} + +.empty-state-icon { + font-size: 4rem; + opacity: 0.3; + margin-bottom: var(--md-spacing-md); +} + +.empty-state-text { + font-size: 1.25rem; + margin-bottom: var(--md-spacing-sm); +} + +.empty-state-subtext { + font-size: 0.875rem; +} + +/* Badge */ +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 10px; + background-color: var(--md-error); + color: white; + font-size: 0.75rem; + font-weight: 500; +} + +/* Toggle Switch */ +.toggle-group { + display: inline-flex; + border: 1px solid var(--md-outline); + border-radius: var(--md-radius-sm); + overflow: hidden; +} + +.toggle-option { + padding: 8px 16px; + border: none; + background-color: var(--md-surface); + color: var(--md-on-surface-variant); + cursor: pointer; + font-family: var(--md-font-family); + font-size: 0.875rem; + font-weight: 500; + transition: all var(--md-transition-fast); + border-right: 1px solid var(--md-divider); +} + +.toggle-option:last-child { + border-right: none; +} + +.toggle-option:hover { + background-color: var(--md-surface-variant); +} + +.toggle-option.active { + background-color: var(--md-primary); + color: white; +} + +/* Metadata Display */ +.metadata-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--md-spacing-md); +} + +.metadata-item { + display: flex; + flex-direction: column; + gap: var(--md-spacing-xs); +} + +.metadata-label { + font-size: 0.75rem; + color: var(--md-on-surface-variant); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.metadata-value { + font-size: 1rem; + font-weight: 500; +} + +/* ============================================ */ +/* Login Screen Styles */ +/* ============================================ */ + +.login-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.login-card { + background: white; + padding: var(--md-spacing-xxl); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + max-width: 400px; + width: 90%; +} + +.login-card h2 { + color: #667eea; + font-weight: 500; +} + +.login-card .form-input:focus { + border-color: #667eea; + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); +} + +.login-card .btn-filled { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + margin-top: var(--md-spacing-md); +} + +.login-card .btn-filled:hover { + opacity: 0.9; +} + +.login-card .btn-filled:disabled { + opacity: 0.6; + cursor: not-allowed; +}