From 9997897411b566efff93f16e88dda3b80dbc3137 Mon Sep 17 00:00:00 2001 From: itqop Date: Thu, 25 Dec 2025 11:47:11 +0300 Subject: [PATCH] 3 stage --- REFACTORING_TODO.md | 137 +++++++------ static/js/ui/annotations.ui.js | 244 +++++++++++++++++++++++ static/js/ui/answer-viewer.ui.js | 246 +++++++++++++++++++++++ static/js/ui/auth.ui.js | 123 ++++++++++++ static/js/ui/loading.ui.js | 47 +++++ static/js/ui/query-builder.ui.js | 261 +++++++++++++++++++++++++ static/js/ui/questions-list.ui.js | 166 ++++++++++++++++ static/js/ui/settings.ui.js | 312 ++++++++++++++++++++++++++++++ 8 files changed, 1472 insertions(+), 64 deletions(-) create mode 100644 static/js/ui/annotations.ui.js create mode 100644 static/js/ui/answer-viewer.ui.js create mode 100644 static/js/ui/auth.ui.js create mode 100644 static/js/ui/loading.ui.js create mode 100644 static/js/ui/query-builder.ui.js create mode 100644 static/js/ui/questions-list.ui.js create mode 100644 static/js/ui/settings.ui.js diff --git a/REFACTORING_TODO.md b/REFACTORING_TODO.md index 9a685e0..d13fcaa 100644 --- a/REFACTORING_TODO.md +++ b/REFACTORING_TODO.md @@ -6,10 +6,10 @@ --- -## 📊 Общий прогресс: 55% +## 📊 Общий прогресс: 90% ``` -[███████████░░░░░░░░░] 55% завершено +[██████████████████░░] 90% завершено ``` --- @@ -180,69 +180,83 @@ --- -### 🔲 Этап 5: UI Components (ОЖИДАЕТ) +### ✅ Этап 5: UI Components (ЗАВЕРШЁН) -**Дата**: - -**Статус**: 🔲 Ожидает +**Дата**: 2025-12-25 +**Статус**: ✅ Готово -#### 5.1. ui/auth.ui.js 🔲 +#### 5.1. ui/auth.ui.js ✅ **Строки из app.js**: 77-132 -- [ ] `showLoginScreen()` - показать экран входа -- [ ] `hideLoginScreen()` - скрыть экран входа -- [ ] `setupListeners()` - подключить обработчики -- [ ] `handleLoginSubmit()` - обработка входа +- [x] `showLoginScreen()` - показать экран входа +- [x] `hideLoginScreen()` - скрыть экран входа +- [x] `handleLogin()` - обработка входа +- [x] `handleLogout()` - обработка выхода +- [x] `setupListeners()` - подключить обработчики -#### 5.2. ui/loading.ui.js 🔲 +**Результат**: 5 функций для UI авторизации ✅ + +#### 5.2. ui/loading.ui.js ✅ **Строки из app.js**: 1137-1145 -- [ ] `show(message)` - показать загрузку -- [ ] `hide()` - скрыть загрузку +- [x] `show(message)` - показать загрузку +- [x] `hide()` - скрыть загрузку -#### 5.3. ui/settings.ui.js 🔲 +**Результат**: 2 функции для индикатора загрузки ✅ + +#### 5.3. ui/settings.ui.js ✅ **Строки из app.js**: 362-813 -- [ ] `open()` - открыть диалог -- [ ] `close()` - закрыть диалог -- [ ] `populate()` - заполнить поля -- [ ] `read()` - прочитать поля -- [ ] `toggleBackendSettings(show)` - показать/скрыть backend настройки -- [ ] `save()` - сохранить настройки -- [ ] `reset()` - сбросить настройки -- [ ] `export()` - экспорт настроек -- [ ] `import()` - импорт настроек -- [ ] `setupListeners()` - подключить обработчики +- [x] `open()` - открыть диалог +- [x] `close()` - закрыть диалог +- [x] `populate()` - заполнить поля +- [x] `read()` - прочитать поля +- [x] `toggleBackendSettings(show)` - показать/скрыть backend настройки +- [x] `save()` - сохранить настройки +- [x] `reset()` - сбросить настройки +- [x] `exportSettings()` - экспорт настроек +- [x] `importSettings()` - импорт настроек +- [x] `setupListeners()` - подключить обработчики -#### 5.4. ui/query-builder.ui.js 🔲 +**Результат**: 10 функций для диалога настроек ✅ + +#### 5.4. ui/query-builder.ui.js ✅ **Строки из app.js**: 643-883, 884-950 -- [ ] `show()` - показать построитель запросов -- [ ] `switchMode(mode)` - переключить режим (questions/raw-json) -- [ ] `validateJSON()` - валидация JSON -- [ ] `handleSendQuery()` - обработка отправки -- [ ] `setupListeners()` - подключить обработчики +- [x] `show()` - показать построитель запросов +- [x] `switchMode(mode)` - переключить режим (questions/raw-json) +- [x] `validateJSONMode()` - валидация JSON +- [x] `handleSendQuery()` - обработка отправки +- [x] `switchTab()` - переключение табов +- [x] `setupListeners()` - подключить обработчики -#### 5.5. ui/questions-list.ui.js 🔲 +**Результат**: 6 функций для построителя запросов ✅ + +#### 5.5. ui/questions-list.ui.js ✅ **Строки из app.js**: 1179-1273 -- [ ] `render()` - рендер списка вопросов -- [ ] `selectAnswer(index)` - выбрать ответ -- [ ] `updateCount()` - обновить счётчик -- [ ] `hasAnnotations(docsSection)` - проверить наличие аннотаций -- [ ] `setupListeners()` - подключить обработчики +- [x] `render()` - рендер списка вопросов +- [x] `selectAnswer(index)` - выбрать ответ +- [x] `hasAnnotationsInDocs(docsSection)` - проверить наличие аннотаций -#### 5.6. ui/answer-viewer.ui.js 🔲 +**Результат**: 3 функции для списка вопросов ✅ + +#### 5.6. ui/answer-viewer.ui.js ✅ **Строки из app.js**: 1279-1443 -- [ ] `render(index)` - рендер ответа -- [ ] `renderBody(elementId, text)` - рендер тела ответа -- [ ] `renderDocuments(containerId, docs, ...)` - рендер документов -- [ ] `toggleExpansion(id)` - раскрыть/свернуть -- [ ] `switchTab(tabButton, tabId)` - переключить таб -- [ ] `setupListeners()` - подключить обработчики +- [x] `show()` - показать просмотрщик +- [x] `render(index)` - рендер ответа +- [x] `renderBody(elementId, text)` - рендер тела ответа +- [x] `renderDocuments(containerId, docs, ...)` - рендер документов +- [x] `toggleExpansion(id)` - раскрыть/свернуть -#### 5.7. ui/annotations.ui.js 🔲 +**Результат**: 5 функций для просмотра ответов ✅ + +#### 5.7. ui/annotations.ui.js ✅ **Строки из app.js**: 1448-1615 -- [ ] `initForAnswer(index)` - инициализация аннотаций -- [ ] `loadForAnswer(index)` - загрузить аннотации -- [ ] `loadSection(section, data)` - загрузить секцию -- [ ] `loadDocuments(section, subsection, docs)` - загрузить документы -- [ ] `setupListeners()` - подключить обработчики -- [ ] `saveDraft()` - сохранить черновик +- [x] `initForAnswer(index)` - инициализация аннотаций +- [x] `loadForAnswer(index)` - загрузить аннотации +- [x] `loadSection(section, data)` - загрузить секцию +- [x] `loadDocuments(section, subsection, docs)` - загрузить документы +- [x] `setupListeners()` - подключить обработчики +- [x] `saveDraft()` - сохранить черновик +- [x] `updateCheckboxStyle()` - обновить стиль чекбокса + +**Результат**: 7 функций для работы с аннотациями ✅ --- @@ -299,7 +313,7 @@ ## 📈 Статистика -### Создано файлов: 12/17 +### Создано файлов: 19/20 | Категория | Создано | Всего | Прогресс | |-----------|---------|-------|----------| @@ -308,10 +322,10 @@ | State | 1 | 1 | 100% ✅ | | Data | 2 | 2 | 100% ✅ | | Services | 4 | 4 | 100% ✅ | -| UI | 0 | 7 | 0% 🔲 | +| UI | 7 | 7 | 100% ✅ | | Main | 0 | 1 | 0% 🔲 | -### Перенесено функций: ~70/~150 +### Перенесено функций: ~108/~150 - ✅ Format utils: 11 функций - ✅ File utils: 6 функций @@ -320,22 +334,17 @@ - ✅ AppState class: ~15 методов - ✅ Storage utils: 19 функций - ✅ Services: ~15 функций (auth 4 + settings 5 + query 6) -- 🔲 Остальное: ~80 функций (в основном UI компоненты) +- ✅ UI Components: ~38 функций (auth 5 + loading 2 + settings 10 + query-builder 6 + questions-list 3 + answer-viewer 5 + annotations 7) +- 🔲 Остальное: ~42 функции (в основном main.js) --- ## 🎯 Следующий шаг -**Этап 5: UI Components** +**Этап 6: Main Entry Point** -Создать UI компоненты (7 файлов): -1. `ui/auth.ui.js` - экран авторизации -2. `ui/loading.ui.js` - индикатор загрузки -3. `ui/settings.ui.js` - диалог настроек -4. `ui/query-builder.ui.js` - построитель запросов -5. `ui/questions-list.ui.js` - список вопросов -6. `ui/answer-viewer.ui.js` - просмотр ответов -7. `ui/annotations.ui.js` - интерфейс аннотаций +Создать главную точку входа: +1. `js/main.js` - импортировать все модули, инициализировать приложение --- @@ -348,4 +357,4 @@ --- -**Последнее обновление**: 2025-12-25 (Этап 4 завершён) +**Последнее обновление**: 2025-12-25 (Этап 5 завершён - 90% готово!) diff --git a/static/js/ui/annotations.ui.js b/static/js/ui/annotations.ui.js new file mode 100644 index 0000000..f16a06d --- /dev/null +++ b/static/js/ui/annotations.ui.js @@ -0,0 +1,244 @@ +/** + * Annotations UI + * + * UI компонент для работы с аннотациями ответов. + */ + +import appState from '../state/appState.js' +import { getInputValue, setInputValue } from '../utils/dom.utils.js' + +/** + * Инициализировать аннотацию для ответа + * @param {number} index - Индекс ответа + */ +export function initForAnswer(index) { + const env = appState.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: {} } + } + } +} + +/** + * Загрузить аннотации для ответа + * @param {number} index - Индекс ответа + */ +export function loadForAnswer(index) { + initForAnswer(index) + + const env = appState.getCurrentEnv() + const annotation = env.annotations[index] + + // Load overall rating + const ratingSelect = document.getElementById('overall-rating') + const overallComment = document.getElementById('overall-comment') + + if (ratingSelect) { + ratingSelect.value = annotation.overall.rating || '' + } + + if (overallComment) { + setInputValue(overallComment, annotation.overall.comment || '') + } + + // Load body annotations + loadSection('body_research', annotation.body_research) + loadSection('body_analytical_hub', annotation.body_analytical_hub) + + // Load document annotations + loadDocuments('docs_from_vectorstore', 'research', annotation.docs_from_vectorstore?.research) + loadDocuments('docs_from_vectorstore', 'analytical_hub', annotation.docs_from_vectorstore?.analytical_hub) + loadDocuments('docs_to_llm', 'research', annotation.docs_to_llm?.research) + loadDocuments('docs_to_llm', 'analytical_hub', annotation.docs_to_llm?.analytical_hub) + + // Setup event listeners for current answer + setupListeners() +} + +/** + * Загрузить аннотацию секции (body) + * @param {string} section - Название секции + * @param {object} data - Данные аннотации + */ +export function loadSection(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) { + setInputValue(textarea, data.comment || '') + } +} + +/** + * Загрузить аннотации документов + * @param {string} section - Секция (docs_from_vectorstore, docs_to_llm) + * @param {string} subsection - Подсекция (research, analytical_hub) + * @param {object} docs - Объект с аннотациями документов + */ +export function loadDocuments(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) { + setInputValue(textarea, data.comment || '') + } + }) +} + +/** + * Настроить обработчики событий для аннотаций + */ +export function setupListeners() { + const env = appState.getCurrentEnv() + const index = env.currentAnswerIndex + + // Overall rating + const ratingSelect = document.getElementById('overall-rating') + const overallComment = document.getElementById('overall-comment') + + if (ratingSelect) { + ratingSelect.onchange = (e) => { + env.annotations[index].overall.rating = e.target.value + saveDraft() + } + } + + if (overallComment) { + overallComment.oninput = (e) => { + env.annotations[index].overall.comment = getInputValue(e.target) + saveDraft() + } + } + + // 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) + saveDraft() + } + } else if (element.tagName === 'TEXTAREA') { + element.oninput = (e) => { + const value = getInputValue(e.target) + + if (docIndex !== undefined) { + // Document annotation + if (!env.annotations[index][section][subsection][docIndex]) { + env.annotations[index][section][subsection][docIndex] = { issues: [], comment: '' } + } + env.annotations[index][section][subsection][docIndex].comment = value + } else if (section !== 'overall') { + // Body annotation + env.annotations[index][section].comment = value + } + + saveDraft() + } + } + }) +} + +/** + * Обновить стиль чекбокса (добавить checked класс к label) + * @param {HTMLElement} checkbox - Чекбокс элемент + */ +export function updateCheckboxStyle(checkbox) { + const label = checkbox.closest('.issue-checkbox') + if (label) { + if (checkbox.checked) { + label.classList.add('checked') + } else { + label.classList.remove('checked') + } + } +} + +/** + * Сохранить черновик аннотаций в localStorage + */ +export function saveDraft() { + const currentEnv = appState.getCurrentEnvironment() + appState.saveEnvironmentToStorage(currentEnv) +} + +// Export as default object +export default { + initForAnswer, + loadForAnswer, + loadSection, + loadDocuments, + setupListeners, + updateCheckboxStyle, + saveDraft +} diff --git a/static/js/ui/answer-viewer.ui.js b/static/js/ui/answer-viewer.ui.js new file mode 100644 index 0000000..fcff29b --- /dev/null +++ b/static/js/ui/answer-viewer.ui.js @@ -0,0 +1,246 @@ +/** + * Answer Viewer UI + * + * UI компонент для просмотра ответов. + */ + +import appState from '../state/appState.js' +import { escapeHtml, formatTime, formatTimestamp, isTableText, parseTextTable } from '../utils/format.utils.js' +import { setElementText, setElementHTML, addClass, removeClass, hideElement, showElement } from '../utils/dom.utils.js' + +/** + * Показать просмотрщик ответов, скрыть построитель запросов + */ +export function show() { + const queryBuilder = document.getElementById('query-builder') + const answerViewer = document.getElementById('answer-viewer') + + if (queryBuilder) { + addClass(queryBuilder, 'hidden') + } + + if (answerViewer) { + removeClass(answerViewer, 'hidden') + } +} + +/** + * Отрендерить ответ по индексу + * @param {number} index - Индекс ответа + * @param {Function} onLoadAnnotations - Callback для загрузки аннотаций + */ +export function render(index, onLoadAnnotations) { + const env = appState.getCurrentEnv() + const answer = env.currentResponse?.answers[index] + + if (!answer) { + console.error('Answer not found at index:', index) + return + } + + const isBackendMode = answer.backend_mode === true + + // Show answer viewer + show() + + // Render question header + setElementText('current-question-number', index + 1) + setElementText('current-question-text', answer.question) + + // Render metadata + setElementText('processing-time', isBackendMode ? 'N/A' : formatTime(answer.processing_time_sec)) + setElementText('request-id', env.requestId || '-') + setElementText('request-timestamp', env.requestTimestamp ? formatTimestamp(env.requestTimestamp) : '-') + + // Render answer bodies + renderBody('body-research-text', answer.body_research) + renderBody('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) { + if (isBackendMode) { + hideElement(docsSection) + } else { + showElement(docsSection) + } + } + + 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 + if (typeof onLoadAnnotations === 'function') { + onLoadAnnotations(index) + } +} + +/** + * Отрендерить тело ответа + * @param {string} elementId - ID элемента + * @param {string} text - Текст ответа + */ +export function renderBody(elementId, text) { + const container = document.getElementById(elementId) + + if (!container) { + console.warn(`Element ${elementId} not found`) + return + } + + if (!text) { + setElementHTML(container, '

Нет данных

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

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

` + setElementHTML(container, html) +} + +/** + * Отрендерить документы + * @param {string} containerId - ID контейнера + * @param {Array} docs - Массив документов + * @param {string} section - Секция (docs_from_vectorstore, docs_to_llm) + * @param {string} subsection - Подсекция (research, analytical_hub) + * @param {number} answerIndex - Индекс ответа + */ +export function renderDocuments(containerId, docs, section, subsection, answerIndex) { + const container = document.getElementById(containerId) + + if (!container) { + console.warn(`Container ${containerId} not found`) + return + } + + if (!docs || docs.length === 0) { + setElementHTML(container, ` +
+
Нет документов
+
+ `) + return + } + + const html = 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('') + + setElementHTML(container, html) +} + +/** + * Переключить раскрытие expansion panel + * @param {string} id - ID панели + */ +export function toggleExpansion(id) { + const panel = document.getElementById(id) + if (panel) { + panel.classList.toggle('expanded') + } +} + +// Export as default object +export default { + show, + render, + renderBody, + renderDocuments, + toggleExpansion +} diff --git a/static/js/ui/auth.ui.js b/static/js/ui/auth.ui.js new file mode 100644 index 0000000..472ed9f --- /dev/null +++ b/static/js/ui/auth.ui.js @@ -0,0 +1,123 @@ +/** + * Auth UI + * + * UI компонент для экрана авторизации. + */ + +import * as authService from '../services/auth.service.js' +import { showElement, hideElement, getInputValue, setInputValue, setElementText } from '../utils/dom.utils.js' + +/** + * Показать экран авторизации + */ +export function showLoginScreen() { + const loginScreen = document.getElementById('login-screen') + const app = document.getElementById('app') + + if (loginScreen) { + loginScreen.style.display = 'flex' + } + + if (app) { + app.style.display = 'none' + } +} + +/** + * Скрыть экран авторизации и показать приложение + */ +export function hideLoginScreen() { + const loginScreen = document.getElementById('login-screen') + const app = document.getElementById('app') + + if (loginScreen) { + loginScreen.style.display = 'none' + } + + if (app) { + app.style.display = 'block' + } +} + +/** + * Обработка авторизации + */ +export async function handleLogin() { + const loginInput = document.getElementById('login-input') + const loginError = document.getElementById('login-error') + const loginBtn = document.getElementById('login-submit-btn') + + if (!loginInput || !loginError || !loginBtn) { + console.error('Login form elements not found') + return + } + + const login = getInputValue(loginInput).trim() + + try { + // Скрыть предыдущие ошибки + hideElement(loginError) + + // Показать состояние загрузки + loginBtn.disabled = true + loginBtn.textContent = 'Вход...' + + // Выполнить вход + await authService.login(login) + console.log('Login successful') + + // Скрыть login screen, показать приложение + hideLoginScreen() + setInputValue(loginInput, '') + } catch (error) { + console.error('Login failed:', error) + setElementText(loginError, error.message || 'Ошибка авторизации') + showElement(loginError) + } finally { + loginBtn.disabled = false + loginBtn.textContent = 'Войти' + } +} + +/** + * Выход из системы + */ +export function handleLogout() { + if (confirm('Вы уверены, что хотите выйти?')) { + authService.logout() + } +} + +/** + * Инициализация обработчиков событий + */ +export function setupListeners() { + const loginBtn = document.getElementById('login-submit-btn') + const loginInput = document.getElementById('login-input') + const logoutBtn = document.getElementById('logout-btn') + + if (loginBtn) { + loginBtn.addEventListener('click', handleLogin) + } + + if (loginInput) { + loginInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + handleLogin() + } + }) + } + + if (logoutBtn) { + logoutBtn.addEventListener('click', handleLogout) + } +} + +// Export as default object +export default { + showLoginScreen, + hideLoginScreen, + handleLogin, + handleLogout, + setupListeners +} diff --git a/static/js/ui/loading.ui.js b/static/js/ui/loading.ui.js new file mode 100644 index 0000000..2180de5 --- /dev/null +++ b/static/js/ui/loading.ui.js @@ -0,0 +1,47 @@ +/** + * Loading UI + * + * UI компонент для индикатора загрузки. + */ + +import { setElementText, addClass, removeClass } from '../utils/dom.utils.js' + +/** + * Показать индикатор загрузки + * @param {string} message - Сообщение для отображения + */ +export function show(message = 'Загрузка...') { + const loadingOverlay = document.getElementById('loading-overlay') + const loadingMessage = document.getElementById('loading-message') + + if (!loadingOverlay) { + console.warn('Loading overlay element not found') + return + } + + if (loadingMessage) { + setElementText(loadingMessage, message) + } + + addClass(loadingOverlay, 'open') +} + +/** + * Скрыть индикатор загрузки + */ +export function hide() { + const loadingOverlay = document.getElementById('loading-overlay') + + if (!loadingOverlay) { + console.warn('Loading overlay element not found') + return + } + + removeClass(loadingOverlay, 'open') +} + +// Export as default object +export default { + show, + hide +} diff --git a/static/js/ui/query-builder.ui.js b/static/js/ui/query-builder.ui.js new file mode 100644 index 0000000..b639273 --- /dev/null +++ b/static/js/ui/query-builder.ui.js @@ -0,0 +1,261 @@ +/** + * Query Builder UI + * + * UI компонент для построителя запросов. + */ + +import appState from '../state/appState.js' +import queryService from '../services/query.service.js' +import loadingUI from './loading.ui.js' +import { validateJSON } from '../utils/validation.utils.js' +import { addClass, removeClass, hideElement, showElement, getInputValue, setElementText } from '../utils/dom.utils.js' +import { showToast } from '../utils/dom.utils.js' + +/** + * Показать построитель запросов + */ +export function show() { + const queryBuilder = document.getElementById('query-builder') + const answerViewer = document.getElementById('answer-viewer') + + if (queryBuilder) { + removeClass(queryBuilder, 'hidden') + } + + if (answerViewer) { + addClass(answerViewer, 'hidden') + } +} + +/** + * Переключить режим запроса (questions / raw-json) + * @param {string} mode - Режим ('questions' или 'raw-json') + */ +export function switchMode(mode) { + // Update toggle buttons + const toggleButtons = document.querySelectorAll('.toggle-option') + toggleButtons.forEach(btn => { + removeClass(btn, 'active') + if (btn.dataset.mode === mode) { + addClass(btn, 'active') + } + }) + + // Show/hide mode panels + const questionsMode = document.getElementById('questions-mode') + const rawJsonMode = document.getElementById('raw-json-mode') + + if (mode === 'questions') { + showElement(questionsMode) + hideElement(rawJsonMode) + } else if (mode === 'raw-json') { + hideElement(questionsMode) + showElement(rawJsonMode) + } +} + +/** + * Валидация JSON в raw-json режиме + * @returns {boolean} True если JSON валиден + */ +export function validateJSONMode() { + const textarea = document.getElementById('json-textarea') + const message = document.getElementById('json-validation-message') + + if (!textarea || !message) { + console.error('JSON textarea or validation message not found') + return false + } + + const jsonText = getInputValue(textarea) + const validation = validateJSON(jsonText) + + if (validation.valid) { + removeClass(textarea, 'error') + removeClass(message, 'error') + addClass(message, 'color-success') + + const count = Array.isArray(validation.data) ? validation.data.length : 0 + setElementText(message, `✓ JSON валиден (${count} вопросов)`) + + return true + } else { + addClass(textarea, 'error') + addClass(message, 'error') + removeClass(message, 'color-success') + setElementText(message, `✗ Ошибка: ${validation.error}`) + + return false + } +} + +/** + * Обработать отправку запроса + * @param {Function} onSuccess - Callback при успешной отправке + */ +export async function handleSendQuery(onSuccess) { + try { + const envSettings = appState.getCurrentEnvSettings() + const currentEnvKey = appState.getCurrentEnvironment() + const apiMode = envSettings?.apiMode || 'bench' + + // Get current mode from toggle + const activeToggle = document.querySelector('.toggle-option.active') + const mode = activeToggle?.dataset.mode || 'questions' + + // Get form values + const questionsText = getInputValue('questions-textarea') + const jsonText = getInputValue('json-textarea') + + // Build request body + const requestBody = queryService.buildRequestBody(mode, questionsText, jsonText) + + // Show loading + const loadingMsg = apiMode === 'backend' + ? 'Отправка запроса к Backend API...' + : 'Отправка запроса к Bench API...' + loadingUI.show(loadingMsg) + + // Send query + const resetSession = envSettings?.resetSessionMode !== false + const apiResponse = await queryService.sendQuery( + currentEnvKey, + apiMode, + requestBody, + resetSession + ) + + // Hide loading + loadingUI.hide() + + // Process response + queryService.processQueryResponse(currentEnvKey, requestBody, apiResponse) + + // Call success callback + if (typeof onSuccess === 'function') { + onSuccess() + } + } catch (error) { + console.error('Query failed:', error) + loadingUI.hide() + showToast(`Ошибка запроса: ${error.message}`, 'error') + } +} + +/** + * Переключить между табами + * @param {HTMLElement} tabButton - Кнопка таба + * @param {string} tabId - ID контента таба + */ +export function switchTab(tabButton, tabId) { + if (!tabButton || !tabId) { + return + } + + // Get all tabs in the same group + const tabsContainer = tabButton.parentElement + const tabs = tabsContainer.querySelectorAll('.tab') + + // Deactivate all tabs + tabs.forEach(tab => removeClass(tab, 'active')) + + // Activate clicked tab + addClass(tabButton, 'active') + + // Find and show corresponding content + const contentContainer = tabsContainer.nextElementSibling + if (contentContainer && contentContainer.classList.contains('tab-content')) { + // Handle nested tabs + const parent = tabsContainer.parentElement + const allContents = parent.querySelectorAll('.tab-content') + + allContents.forEach(content => { + if (content.id === tabId) { + addClass(content, 'active') + } else if (!content.contains(tabsContainer)) { + removeClass(content, 'active') + } + }) + } else { + // Handle top-level tabs + const parent = tabsContainer.parentElement + const allContents = parent.querySelectorAll(':scope > .tab-content') + + allContents.forEach(content => { + if (content.id === tabId) { + addClass(content, 'active') + } else { + removeClass(content, 'active') + } + }) + + // 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) { + addClass(tab, 'active') + } else { + removeClass(tab, 'active') + } + }) + + // Find nested tab-content elements + 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 => removeClass(content, 'active')) + if (nestedContents.length > 0) { + addClass(nestedContents[0], 'active') + } + } + } + } +} + +/** + * Инициализация обработчиков событий + * @param {Function} onQuerySuccess - Callback при успешной отправке запроса + */ +export function setupListeners(onQuerySuccess) { + // Toggle mode buttons + const toggleButtons = document.querySelectorAll('.toggle-option') + toggleButtons.forEach(btn => { + btn.addEventListener('click', () => { + const mode = btn.dataset.mode + if (mode) { + switchMode(mode) + } + }) + }) + + // JSON validation + const jsonTextarea = document.getElementById('json-textarea') + if (jsonTextarea) { + jsonTextarea.addEventListener('input', validateJSONMode) + } + + // Send query button + const sendQueryBtn = document.getElementById('send-query-btn') + if (sendQueryBtn) { + sendQueryBtn.addEventListener('click', () => handleSendQuery(onQuerySuccess)) + } +} + +// Export as default object +export default { + show, + switchMode, + validateJSONMode, + handleSendQuery, + switchTab, + setupListeners +} diff --git a/static/js/ui/questions-list.ui.js b/static/js/ui/questions-list.ui.js new file mode 100644 index 0000000..9d40503 --- /dev/null +++ b/static/js/ui/questions-list.ui.js @@ -0,0 +1,166 @@ +/** + * Questions List UI + * + * UI компонент для списка вопросов. + */ + +import appState from '../state/appState.js' +import { escapeHtml, formatTime, pluralize } from '../utils/format.utils.js' +import { setElementText, setElementHTML } from '../utils/dom.utils.js' + +/** + * Проверить наличие аннотаций в секции документов + * @param {object} docsSection - Секция документов + * @returns {boolean} True если есть аннотации + */ +export 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 +} + +/** + * Отрендерить список вопросов + */ +export function render() { + const container = document.getElementById('questions-list') + const countElement = document.getElementById('questions-count') + + if (!container) { + console.error('Questions list container not found') + return + } + + const env = appState.getCurrentEnv() + const response = env.currentResponse + + if (!response || !response.answers || response.answers.length === 0) { + setElementHTML(container, ` +
+
+ question_answer +
+
Нет данных
+
Отправьте запрос к RAG бэкенду
+
+ `) + + if (countElement) { + setElementText(countElement, '0 вопросов') + } + + return + } + + // Update count + if (countElement) { + const count = response.answers.length + const text = `${count} ${pluralize(count, 'вопрос', 'вопроса', 'вопросов')}` + setElementText(countElement, text) + } + + // Render questions + const html = 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('') + + setElementHTML(container, html) +} + +/** + * Выбрать ответ по индексу + * @param {number} index - Индекс ответа + * @param {Function} onSelect - Callback при выборе + */ +export function selectAnswer(index, onSelect) { + const env = appState.getCurrentEnv() + env.currentAnswerIndex = index + + // Re-render questions list to update active state + render() + + // Call callback if provided + if (typeof onSelect === 'function') { + onSelect(index) + } +} + +// Export as default object +export default { + render, + selectAnswer, + hasAnnotationsInDocs +} diff --git a/static/js/ui/settings.ui.js b/static/js/ui/settings.ui.js new file mode 100644 index 0000000..6317544 --- /dev/null +++ b/static/js/ui/settings.ui.js @@ -0,0 +1,312 @@ +/** + * Settings UI + * + * UI компонент для диалога настроек. + */ + +import appState from '../state/appState.js' +import settingsService from '../services/settings.service.js' +import { defaultSettings } from '../data/defaults.js' +import { downloadJSON, loadFileAsJSON } from '../utils/file.utils.js' +import { showToast, getInputValue, setInputValue, addClass, removeClass, showElement, hideElement } from '../utils/dom.utils.js' + +/** + * Открыть диалог настроек + */ +export function open() { + populate() + const dialog = document.getElementById('settings-dialog') + if (dialog) { + addClass(dialog, 'open') + } +} + +/** + * Закрыть диалог настроек + */ +export function close() { + const dialog = document.getElementById('settings-dialog') + if (dialog) { + removeClass(dialog, 'open') + } +} + +/** + * Заполнить диалог настроек текущими значениями + */ +export function populate() { + const env = appState.getCurrentEnvironment() + const envSettings = appState.getCurrentEnvSettings() + + if (!envSettings) { + console.error('Environment settings not found') + return + } + + // Set environment selector + const envSelector = document.getElementById('settings-env-selector') + if (envSelector) { + envSelector.value = env + } + + // API Mode + const apiMode = envSettings.apiMode || 'bench' + const apiModeSelect = document.getElementById('setting-api-mode') + if (apiModeSelect) { + apiModeSelect.value = apiMode + } + + toggleBackendSettings(apiMode === 'backend') + + // Populate environment-specific fields (только редактируемые пользователем) + setInputValue('setting-bearer-token', envSettings.bearerToken || '') + setInputValue('setting-system-platform', envSettings.systemPlatform || '') + setInputValue('setting-system-platform-user', envSettings.systemPlatformUser || '') + + // Backend mode fields + setInputValue('setting-platform-user-id', envSettings.platformUserId || '') + setInputValue('setting-platform-id', envSettings.platformId || '') + + const classifyCheckbox = document.getElementById('setting-with-classify') + if (classifyCheckbox) { + classifyCheckbox.checked = envSettings.withClassify || false + } + + const resetSessionCheckbox = document.getElementById('setting-reset-session-mode') + if (resetSessionCheckbox) { + resetSessionCheckbox.checked = envSettings.resetSessionMode !== false + } +} + +/** + * Показать/скрыть backend настройки + * @param {boolean} show - Показать или скрыть + */ +export function toggleBackendSettings(show) { + const backendSettings = document.getElementById('backend-settings') + const backendHeader = document.getElementById('backend-settings-header') + + if (show) { + showElement(backendSettings) + showElement(backendHeader) + } else { + hideElement(backendSettings) + hideElement(backendHeader) + } +} + +/** + * Прочитать настройки из диалога + * @returns {object} Обновлённые настройки + */ +export function read() { + const envSelector = document.getElementById('settings-env-selector') + if (!envSelector) { + console.error('Settings env selector not found') + return null + } + + const env = envSelector.value + + // Update environment-specific settings + const updatedSettings = JSON.parse(JSON.stringify(appState.settings)) // Deep copy + + if (!updatedSettings.environments[env]) { + console.error(`Environment ${env} not found in settings`) + return null + } + + updatedSettings.environments[env] = { + name: updatedSettings.environments[env].name, + apiMode: getInputValue('setting-api-mode'), + bearerToken: getInputValue('setting-bearer-token').trim(), + systemPlatform: getInputValue('setting-system-platform').trim(), + systemPlatformUser: getInputValue('setting-system-platform-user').trim(), + platformUserId: getInputValue('setting-platform-user-id').trim(), + platformId: getInputValue('setting-platform-id').trim(), + withClassify: document.getElementById('setting-with-classify')?.checked || false, + resetSessionMode: document.getElementById('setting-reset-session-mode')?.checked !== false + } + + return updatedSettings +} + +/** + * Сохранить настройки на сервер + */ +export async function save() { + const saveBtn = document.getElementById('save-settings-btn') + + if (!saveBtn) { + console.error('Save settings button not found') + return + } + + saveBtn.disabled = true + saveBtn.textContent = 'Сохранение...' + + try { + const updatedSettings = read() + + if (!updatedSettings) { + throw new Error('Failed to read settings from dialog') + } + + await settingsService.saveToServer(updatedSettings) + showToast('Настройки сохранены на сервере', 'success') + close() + } catch (error) { + console.error('Failed to save settings:', error) + showToast(`Ошибка сохранения: ${error.message}`, 'error') + } finally { + saveBtn.disabled = false + saveBtn.textContent = 'Сохранить' + } +} + +/** + * Сбросить настройки к дефолтным + */ +export async function reset() { + if (!confirm('Сбросить все настройки к значениям по умолчанию?')) { + return + } + + try { + const resetSettings = { ...defaultSettings } + await settingsService.saveToServer(resetSettings) + populate() + showToast('Настройки сброшены и сохранены на сервере', 'success') + } catch (error) { + console.error('Failed to reset settings:', error) + showToast(`Ошибка сброса: ${error.message}`, 'error') + } +} + +/** + * Экспортировать настройки в JSON файл + */ +export function exportSettings() { + const filename = 'brief-bench-settings.json' + downloadJSON(appState.settings, filename) + showToast('Настройки экспортированы в ' + filename, 'success') +} + +/** + * Импортировать настройки из JSON файла + */ +export async 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 settings = await loadFileAsJSON(file) + + // Validate basic structure + if (typeof settings !== 'object' || settings === null) { + throw new Error('Файл настроек должен содержать JSON объект') + } + + // Merge with defaults to ensure all required fields exist + const mergedSettings = { + ...defaultSettings, + ...settings + } + + // Save to server + await settingsService.saveToServer(mergedSettings) + populate() + showToast('Настройки импортированы и сохранены на сервере', 'success') + } catch (error) { + console.error('Failed to import settings:', error) + showToast(`Ошибка импорта: ${error.message}`, 'error') + } + } + + input.click() +} + +/** + * Обработчик изменения окружения в селекторе + */ +export function handleEnvironmentChange() { + populate() +} + +/** + * Обработчик изменения API режима + */ +export function handleApiModeChange() { + const apiModeSelect = document.getElementById('setting-api-mode') + if (apiModeSelect) { + const apiMode = apiModeSelect.value + toggleBackendSettings(apiMode === 'backend') + } +} + +/** + * Инициализация обработчиков событий + */ +export function setupListeners() { + const openBtn = document.getElementById('open-settings-btn') + const closeBtn = document.getElementById('close-settings-btn') + const saveBtn = document.getElementById('save-settings-btn') + const resetBtn = document.getElementById('reset-settings-btn') + const exportBtn = document.getElementById('export-settings-btn') + const importBtn = document.getElementById('import-settings-btn') + const envSelector = document.getElementById('settings-env-selector') + const apiModeSelect = document.getElementById('setting-api-mode') + + if (openBtn) { + openBtn.addEventListener('click', open) + } + + if (closeBtn) { + closeBtn.addEventListener('click', close) + } + + if (saveBtn) { + saveBtn.addEventListener('click', save) + } + + if (resetBtn) { + resetBtn.addEventListener('click', reset) + } + + if (exportBtn) { + exportBtn.addEventListener('click', exportSettings) + } + + if (importBtn) { + importBtn.addEventListener('click', importSettings) + } + + if (envSelector) { + envSelector.addEventListener('change', handleEnvironmentChange) + } + + if (apiModeSelect) { + apiModeSelect.addEventListener('change', handleApiModeChange) + } +} + +// Export as default object +export default { + open, + close, + populate, + read, + save, + reset, + exportSettings, + importSettings, + toggleBackendSettings, + handleEnvironmentChange, + handleApiModeChange, + setupListeners +}