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 `
+
+ `
+ }).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 `
+
+
+
+
${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
+}