feat: DocAgent MVP — browser-only LLM document analyzer
Adds index.html, styles.css, app.js implementing the TRD v1.0 MVP: file upload (PDF/DOCX/TXT/MD/CSV/JSON/XML/HTML/RTF), 6 analysis modes, streaming Claude API call from browser, Markdown rendering, .md export, API key persisted in localStorage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
21e963d83f
commit
fc1bc44b06
324
TRD.md
324
TRD.md
@ -0,0 +1,324 @@
|
||||
# TRD — Агент анализа документов и оценки реализуемости требований
|
||||
|
||||
**Версия:** 1.0
|
||||
**Дата:** 19.05.2026
|
||||
**Статус:** Draft
|
||||
**Автор:** BA
|
||||
|
||||
---
|
||||
|
||||
## 1. Введение
|
||||
|
||||
### 1.1 Назначение документа
|
||||
|
||||
Настоящий документ описывает технические требования к разработке агента на базе LLM, предназначенного для автоматизированного анализа текстовых документов и файлов с оценкой реализуемости содержащихся в них требований.
|
||||
|
||||
### 1.2 Контекст
|
||||
|
||||
Специалисты (бизнес-аналитики, менеджеры проектов, технические эксперты) регулярно работают с объёмными документами — ТЗ, спецификациями, договорами, отчётами. Ручная разборка занимает значительное время. Агент автоматизирует первичный разбор, классификацию требований и их оценку по критериям реализуемости.
|
||||
|
||||
### 1.3 Область применения
|
||||
|
||||
- Корпоративные пользователи, работающие с документацией
|
||||
- Бизнес-аналитики и системные аналитики
|
||||
- Менеджеры проектов и продуктов
|
||||
- Технические эксперты, проводящие экспертизу документов
|
||||
|
||||
---
|
||||
|
||||
## 2. Глоссарий
|
||||
|
||||
| Термин | Определение |
|
||||
|---|---|
|
||||
| LLM | Large Language Model — большая языковая модель |
|
||||
| Агент | Программная система, управляемая LLM, способная выполнять задачи с доступом к инструментам |
|
||||
| Реализуемость | Степень выполнимости требования с учётом технических, временных и ресурсных ограничений |
|
||||
| ТЗ | Техническое задание |
|
||||
| RAG | Retrieval-Augmented Generation — генерация с поиском по базе знаний |
|
||||
| Паспорт кейса | Внутренний документ, описывающий назначение, входы/выходы и ограничения агента |
|
||||
| Система-оркестратор | Компонент, управляющий последовательностью вызовов инструментов агента |
|
||||
|
||||
---
|
||||
|
||||
## 3. Функциональные требования
|
||||
|
||||
### 3.1 Ввод данных
|
||||
|
||||
#### 3.1.1 Загрузка файлов
|
||||
|
||||
| ID | Требование | Приоритет |
|
||||
|---|---|---|
|
||||
| FR-01 | Система должна принимать файлы форматов: `.txt`, `.pdf`, `.docx`, `.doc`, `.md`, `.csv`, `.json`, `.xml`, `.html`, `.rtf` | Must |
|
||||
| FR-02 | Максимальный размер одного файла — 20 МБ | Must |
|
||||
| FR-03 | Поддержка загрузки нескольких файлов одновременно (до 5 файлов) | Should |
|
||||
| FR-04 | PDF с текстовым слоем обрабатывается напрямую; сканированный PDF — через OCR-препроцессор | Should |
|
||||
| FR-05 | Файл `.docx` / `.doc` должен быть преобразован в plain text перед отправкой в LLM | Must |
|
||||
|
||||
#### 3.1.2 Ввод текста
|
||||
|
||||
| ID | Требование | Приоритет |
|
||||
|---|---|---|
|
||||
| FR-06 | Пользователь может вставить текст вручную объёмом до 200 000 символов | Must |
|
||||
| FR-07 | Счётчик символов в режиме реального времени | Should |
|
||||
| FR-08 | Если текст превышает контекстное окно модели — применяется стратегия чанкинга с агрегацией результатов | Must |
|
||||
|
||||
#### 3.1.3 Дополнительный контекст
|
||||
|
||||
| ID | Требование | Приоритет |
|
||||
|---|---|---|
|
||||
| FR-09 | Пользователь может ввести свободный запрос с уточнением задачи (дополнительный промпт) | Should |
|
||||
| FR-10 | Пользователь может указать параметры команды / проекта (размер команды, технологический стек, сроки) для оценки реализуемости | Should |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Типы анализа
|
||||
|
||||
Система должна поддерживать следующие режимы:
|
||||
|
||||
| ID | Тип | Описание | Приоритет |
|
||||
|---|---|---|---|
|
||||
| FR-11 | **Полный анализ** | Запускает все нижеперечисленные типы последовательно, формирует сводный отчёт | Must |
|
||||
| FR-12 | **Анализ требований** | Извлечение всех требований из текста, их классификация (функциональные / нефункциональные / бизнес / ограничения) и нумерация | Must |
|
||||
| FR-13 | **Оценка реализуемости** | Для каждого извлечённого требования — оценка по шкале Высокая / Средняя / Низкая с обоснованием | Must |
|
||||
| FR-14 | **Анализ рисков** | Выявление потенциальных проблем, противоречий, неоднозначностей и пробелов в документе | Must |
|
||||
| FR-15 | **Краткое резюме** | Структурированное резюме документа: цель, ключевые положения, участники, сроки | Must |
|
||||
| FR-16 | **Рекомендации** | Конкретные предложения по доработке / уточнению документа | Should |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Обработка и генерация ответа
|
||||
|
||||
| ID | Требование | Приоритет |
|
||||
|---|---|---|
|
||||
| FR-17 | Ответ агента должен быть структурирован в формате Markdown с заголовками, списками и таблицами | Must |
|
||||
| FR-18 | Каждое извлечённое требование должно иметь уникальный идентификатор (например, REQ-001) | Must |
|
||||
| FR-19 | Оценка реализуемости сопровождается кратким обоснованием (1–3 предложения) | Must |
|
||||
| FR-20 | Агент должен явно указывать, если в документе недостаточно информации для уверенной оценки | Must |
|
||||
| FR-21 | Итоговый отчёт содержит сводную таблицу требований с оценками реализуемости | Should |
|
||||
| FR-22 | Поддержка потоковой передачи ответа (streaming) для улучшения UX | Should |
|
||||
| FR-23 | Язык ответа соответствует языку входного документа (авто-определение) | Should |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Экспорт результатов
|
||||
|
||||
| ID | Требование | Приоритет |
|
||||
|---|---|---|
|
||||
| FR-24 | Пользователь может скопировать результат в буфер обмена | Must |
|
||||
| FR-25 | Результат можно скачать в формате `.md` (Markdown) | Must |
|
||||
| FR-26 | Экспорт в `.docx` — опциональная функция второй очереди | Could |
|
||||
|
||||
---
|
||||
|
||||
## 4. Нефункциональные требования
|
||||
|
||||
### 4.1 Производительность
|
||||
|
||||
| ID | Требование |
|
||||
|---|---|
|
||||
| NFR-01 | Время ответа для документа до 10 000 символов — не более 30 секунд |
|
||||
| NFR-02 | Время ответа для документа 10 000–100 000 символов — не более 90 секунд |
|
||||
| NFR-03 | При использовании чанкинга — промежуточные результаты отображаются пользователю по мере готовности |
|
||||
|
||||
### 4.2 Надёжность
|
||||
|
||||
| ID | Требование |
|
||||
|---|---|
|
||||
| NFR-04 | При ошибке API — система отображает понятное сообщение пользователю и предлагает повторить |
|
||||
| NFR-05 | Таймаут на вызов LLM API — 120 секунд |
|
||||
| NFR-06 | Обработка rate-limit ошибок с автоматическим retry (до 3 попыток с экспоненциальной задержкой) |
|
||||
|
||||
### 4.3 Безопасность и конфиденциальность
|
||||
|
||||
| ID | Требование |
|
||||
|---|---|
|
||||
| NFR-07 | Содержимое загруженных файлов и введённых текстов не сохраняется на сервере после завершения сессии |
|
||||
| NFR-08 | API-ключ пользователя хранится только в `localStorage` браузера и не передаётся на серверную сторону (в browser-only реализации) |
|
||||
| NFR-09 | Передача данных осуществляется только по HTTPS |
|
||||
| NFR-10 | Обработка персональных данных соответствует внутренней политике ИБ организации |
|
||||
|
||||
### 4.4 Удобство использования
|
||||
|
||||
| ID | Требование |
|
||||
|---|---|
|
||||
| NFR-11 | Интерфейс поддерживает drag-and-drop загрузку файлов |
|
||||
| NFR-12 | Визуальная индикация прогресса обработки (spinner + этапы) |
|
||||
| NFR-13 | Адаптивный дизайн для мобильных устройств |
|
||||
| NFR-14 | Поддержка браузеров: Chrome 100+, Firefox 100+, Safari 16+, Edge 100+ |
|
||||
|
||||
---
|
||||
|
||||
## 5. Архитектура агента
|
||||
|
||||
### 5.1 Общая схема
|
||||
|
||||
```
|
||||
Пользователь
|
||||
│
|
||||
▼
|
||||
[Веб-интерфейс / Лендинг]
|
||||
│ (файл / текст + тип анализа + доп. контекст)
|
||||
▼
|
||||
[Препроцессор]
|
||||
├── Парсинг файла (PDF → text, DOCX → text)
|
||||
├── Нормализация текста
|
||||
└── Чанкинг при превышении лимита токенов
|
||||
│
|
||||
▼
|
||||
[Оркестратор агента]
|
||||
├── Формирование системного промпта
|
||||
├── Выбор стратегии анализа (одиночный / multi-chunk)
|
||||
└── Управление вызовами LLM API
|
||||
│
|
||||
▼
|
||||
[LLM — Claude claude-sonnet-4-20250514]
|
||||
│ (структурированный ответ в Markdown)
|
||||
▼
|
||||
[Постпроцессор]
|
||||
├── Парсинг и валидация Markdown
|
||||
├── Агрегация чанков (если применимо)
|
||||
└── Формирование финального отчёта
|
||||
│
|
||||
▼
|
||||
[Рендер результата + Экспорт]
|
||||
```
|
||||
|
||||
### 5.2 Стратегия чанкинга
|
||||
|
||||
Если размер текста превышает `~150 000 токенов`:
|
||||
|
||||
1. Текст разбивается на чанки по `~100 000 токенов` с перекрытием `~500 токенов`
|
||||
2. Каждый чанк анализируется отдельно
|
||||
3. Результаты агрегируются в финальный отчёт через отдельный вызов LLM (сводный промпт)
|
||||
|
||||
---
|
||||
|
||||
## 6. Системный промпт
|
||||
|
||||
### 6.1 Базовый системный промпт
|
||||
|
||||
```
|
||||
Ты — эксперт-аналитик документов. Твоя задача — провести глубокий и структурированный анализ предоставленного документа.
|
||||
|
||||
ИНСТРУКЦИИ:
|
||||
1. Всегда отвечай на языке входного документа
|
||||
2. Используй структурированный Markdown: заголовки H2/H3, таблицы, списки
|
||||
3. Не придумывай информацию — опирайся только на содержимое документа
|
||||
4. Если информации недостаточно для уверенного вывода — явно укажи это
|
||||
5. Каждому найденному требованию присваивай ID формата REQ-XXX
|
||||
|
||||
СТРУКТУРА ОТВЕТА (адаптируй под запрошенный тип анализа):
|
||||
- ## Общее резюме документа
|
||||
- ## Извлечённые требования (таблица с ID, типом, описанием)
|
||||
- ## Оценка реализуемости (таблица: ID | Требование | Оценка | Обоснование)
|
||||
- ## Выявленные риски и противоречия
|
||||
- ## Рекомендации
|
||||
- ## Итоговые выводы
|
||||
```
|
||||
|
||||
### 6.2 Промпт для оценки реализуемости
|
||||
|
||||
Оценка проводится по трём критериям:
|
||||
|
||||
| Критерий | Высокая | Средняя | Низкая |
|
||||
|---|---|---|---|
|
||||
| **Техническая** | Стандартные технологии, понятная реализация | Требует нестандартных решений | Технически сложно / невозможно текущими средствами |
|
||||
| **Ресурсная** | Укладывается в типичные рамки | Требует дополнительных ресурсов | Явно недостаточно ресурсов |
|
||||
| **Временная** | Чёткие, реалистичные сроки | Сжатые, но выполнимые сроки | Нереалистичные или отсутствующие сроки |
|
||||
|
||||
---
|
||||
|
||||
## 7. Интерфейсные требования
|
||||
|
||||
### 7.1 Основные экраны / состояния
|
||||
|
||||
| Состояние | Описание |
|
||||
|---|---|
|
||||
| **Idle** | Пустая форма ввода, выбор типа анализа |
|
||||
| **File selected** | Отображение загруженных файлов с возможностью удаления |
|
||||
| **Processing** | Индикатор прогресса с отображением текущего этапа |
|
||||
| **Result** | Структурированный результат с вкладками и кнопками экспорта |
|
||||
| **Error** | Понятное сообщение об ошибке с инструкцией |
|
||||
|
||||
### 7.2 Требования к отображению результата
|
||||
|
||||
- Результат рендерится как Markdown (не plain text)
|
||||
- Длинные таблицы допускают горизонтальную прокрутку
|
||||
- Бейджи оценки реализуемости: 🟢 Высокая / 🟡 Средняя / 🔴 Низкая
|
||||
|
||||
---
|
||||
|
||||
## 8. Интеграции и зависимости
|
||||
|
||||
| Компонент | Технология / Решение | Назначение |
|
||||
|---|---|---|
|
||||
| LLM | Anthropic Claude API (`claude-sonnet-4-20250514`) | Основной движок анализа |
|
||||
| PDF parsing | `pdf-parse` (Node.js) / `PyMuPDF` (Python) | Извлечение текста из PDF |
|
||||
| DOCX parsing | `mammoth.js` (browser) / `python-docx` | Извлечение текста из DOCX |
|
||||
| Markdown рендер | `marked.js` / `markdown-it` | Отображение результата |
|
||||
| Экспорт `.docx` | `docx.js` (опционально, 2-я очередь) | Скачивание результата |
|
||||
|
||||
---
|
||||
|
||||
## 9. Ограничения и известные риски
|
||||
|
||||
| # | Риск / Ограничение | Митигация |
|
||||
|---|---|---|
|
||||
| R-01 | Сканированные PDF без текстового слоя не читаются | Интеграция OCR-сервиса (Tesseract) в 2-й очереди |
|
||||
| R-02 | Очень длинные документы (>500 страниц) могут давать неточный агрегированный анализ | Ограничение на объём + предупреждение пользователю |
|
||||
| R-03 | LLM может «галлюцинировать» требования, которых нет в документе | Промпт-инструкция + пользовательская проверка |
|
||||
| R-04 | Секретные / конфиденциальные документы не должны передаваться через внешний API | Предупреждение в интерфейсе; on-premise вариант в roadmap |
|
||||
| R-05 | Нестабильность API (rate limits, outages) | Retry-логика, graceful degradation |
|
||||
| R-06 | Оценка реализуемости субъективна без конкретного контекста проекта | Поле «доп. контекст» для уточнения условий |
|
||||
|
||||
---
|
||||
|
||||
## 10. Roadmap
|
||||
|
||||
### v1.0 — MVP (текущий скоуп)
|
||||
- [x] Ввод текста вручную
|
||||
- [x] Загрузка TXT, PDF (текстовые), DOCX
|
||||
- [x] 6 типов анализа
|
||||
- [x] Стриминг ответа
|
||||
- [x] Экспорт `.md`
|
||||
|
||||
### v1.1
|
||||
- [ ] OCR для сканированных PDF
|
||||
- [ ] Чанкинг для документов >150k токенов
|
||||
- [ ] История сессий (localStorage)
|
||||
- [ ] Экспорт в `.docx`
|
||||
|
||||
### v2.0
|
||||
- [ ] Сравнительный анализ нескольких документов
|
||||
- [ ] RAG по базе знаний организации
|
||||
- [ ] Пользовательские шаблоны промптов
|
||||
- [ ] On-premise / self-hosted вариант развёртывания
|
||||
- [ ] Интеграция с Confluence, SharePoint
|
||||
|
||||
---
|
||||
|
||||
## 11. Критерии приёмки (DoD)
|
||||
|
||||
| # | Критерий |
|
||||
|---|---|
|
||||
| AC-01 | Агент корректно извлекает требования из тестового ТЗ на 10+ страниц |
|
||||
| AC-02 | Каждое требование получает уникальный ID и оценку реализуемости с обоснованием |
|
||||
| AC-03 | Анализ документа до 10 000 символов завершается за ≤ 30 секунд |
|
||||
| AC-04 | Результат корректно рендерится в Markdown и доступен для экспорта |
|
||||
| AC-05 | Система не падает при загрузке файла >5 МБ и не превышающего лимиты |
|
||||
| AC-06 | При ошибке API отображается понятное сообщение без технических деталей |
|
||||
| AC-07 | Интерфейс работает корректно в Chrome, Firefox, Safari, Edge последних версий |
|
||||
|
||||
---
|
||||
|
||||
## 12. Согласование
|
||||
|
||||
| Роль | ФИО | Дата | Статус |
|
||||
|---|---|---|---|
|
||||
| Бизнес-аналитик | | | Подготовлен |
|
||||
| Технический руководитель | | | На согласовании |
|
||||
| Заказчик / владелец продукта | | | На согласовании |
|
||||
|
||||
---
|
||||
|
||||
*Документ подготовлен в соответствии со стандартами BA-документации проекта.*
|
||||
403
app.js
Normal file
403
app.js
Normal file
@ -0,0 +1,403 @@
|
||||
// DocAgent — browser-only LLM document analysis client.
|
||||
|
||||
const MODEL = 'claude-sonnet-4-20250514';
|
||||
const API_URL = 'https://api.anthropic.com/v1/messages';
|
||||
const MAX_FILES = 5;
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024;
|
||||
const MAX_TEXT_CHARS = 200000;
|
||||
const MAX_TOKENS = 8192;
|
||||
|
||||
const SYSTEM_PROMPT = `Ты — эксперт-аналитик документов. Твоя задача — провести глубокий и структурированный анализ предоставленного документа.
|
||||
|
||||
ИНСТРУКЦИИ:
|
||||
1. Всегда отвечай на языке входного документа.
|
||||
2. Используй структурированный Markdown: заголовки H2/H3, таблицы, списки.
|
||||
3. Не придумывай информацию — опирайся только на содержимое документа.
|
||||
4. Если информации недостаточно для уверенного вывода — явно укажи это.
|
||||
5. Каждому найденному требованию присваивай ID формата REQ-XXX.
|
||||
|
||||
ОЦЕНКА РЕАЛИЗУЕМОСТИ — три критерия:
|
||||
- Техническая (стандартные технологии → высокая; нестандартные → средняя; технически сложно/невозможно → низкая).
|
||||
- Ресурсная (типичные рамки → высокая; нужны доп. ресурсы → средняя; явно недостаточно → низкая).
|
||||
- Временная (реалистичные сроки → высокая; сжатые но выполнимые → средняя; нереалистичные/отсутствуют → низкая).
|
||||
|
||||
Бейджи: 🟢 Высокая / 🟡 Средняя / 🔴 Низкая.`;
|
||||
|
||||
const ANALYSIS_PROMPTS = {
|
||||
full: `Выполни ПОЛНЫЙ анализ документа. Сформируй отчёт со следующими разделами:
|
||||
## Общее резюме документа
|
||||
## Извлечённые требования
|
||||
Таблица с колонками: ID | Тип (FR/NFR/Бизнес/Ограничение) | Описание.
|
||||
## Оценка реализуемости
|
||||
Таблица: ID | Требование | Техническая | Ресурсная | Временная | Итоговая | Обоснование.
|
||||
## Выявленные риски и противоречия
|
||||
## Рекомендации
|
||||
## Итоговые выводы`,
|
||||
requirements: `Извлеки ВСЕ требования из документа. Для каждого: ID (REQ-XXX), тип (функциональное/нефункциональное/бизнес/ограничение), описание, цитата-источник. Выведи таблицей.`,
|
||||
feasibility: `Извлеки требования и оцени реализуемость каждого по трём критериям (Техническая / Ресурсная / Временная) с обоснованием 1–3 предложения. Сводная таблица + итоговая оценка по документу в целом.`,
|
||||
risks: `Проведи АНАЛИЗ РИСКОВ документа: противоречия, неоднозначности, пробелы, потенциальные технические/организационные риски. Каждый риск: ID (R-XX), описание, последствие, рекомендация по митигации.`,
|
||||
summary: `Сформируй КРАТКОЕ структурированное резюме: цель документа, ключевые положения, участники/роли, сроки, ограничения. Без лишних рассуждений.`,
|
||||
recommendations: `Сформулируй КОНКРЕТНЫЕ рекомендации по доработке документа: что уточнить, что добавить, что переформулировать. Каждая рекомендация — actionable.`,
|
||||
};
|
||||
|
||||
const state = {
|
||||
files: [],
|
||||
abortController: null,
|
||||
lastResultMd: '',
|
||||
};
|
||||
|
||||
// --- DOM ---
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
const els = {
|
||||
apiKey: $('#apiKey'),
|
||||
saveKey: $('#saveKey'),
|
||||
clearKey: $('#clearKey'),
|
||||
keyStatus: $('#keyStatus'),
|
||||
dropZone: $('#dropZone'),
|
||||
fileInput: $('#fileInput'),
|
||||
fileList: $('#fileList'),
|
||||
textInput: $('#textInput'),
|
||||
charCount: $('#charCount'),
|
||||
extraContext: $('#extraContext'),
|
||||
runBtn: $('#runBtn'),
|
||||
stopBtn: $('#stopBtn'),
|
||||
resultSection: $('#resultSection'),
|
||||
result: $('#result'),
|
||||
progress: $('#progress'),
|
||||
progressText: $('#progressText'),
|
||||
copyBtn: $('#copyBtn'),
|
||||
downloadBtn: $('#downloadBtn'),
|
||||
errorSection: $('#errorSection'),
|
||||
errorText: $('#errorText'),
|
||||
};
|
||||
|
||||
// --- API key persistence ---
|
||||
function loadKey() {
|
||||
const k = localStorage.getItem('anthropic_api_key');
|
||||
if (k) {
|
||||
els.apiKey.value = k;
|
||||
els.keyStatus.textContent = '✓ Ключ загружен';
|
||||
els.keyStatus.className = 'status ok';
|
||||
}
|
||||
}
|
||||
|
||||
els.saveKey.addEventListener('click', () => {
|
||||
const k = els.apiKey.value.trim();
|
||||
if (!k.startsWith('sk-ant-')) {
|
||||
els.keyStatus.textContent = 'Ключ должен начинаться с sk-ant-';
|
||||
els.keyStatus.className = 'status err';
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('anthropic_api_key', k);
|
||||
els.keyStatus.textContent = '✓ Сохранён';
|
||||
els.keyStatus.className = 'status ok';
|
||||
});
|
||||
|
||||
els.clearKey.addEventListener('click', () => {
|
||||
localStorage.removeItem('anthropic_api_key');
|
||||
els.apiKey.value = '';
|
||||
els.keyStatus.textContent = 'Ключ удалён';
|
||||
els.keyStatus.className = 'status';
|
||||
});
|
||||
|
||||
// --- File handling ---
|
||||
els.dropZone.addEventListener('click', (e) => {
|
||||
if (e.target.tagName !== 'LABEL') els.fileInput.click();
|
||||
});
|
||||
|
||||
els.dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
els.dropZone.classList.add('drag-over');
|
||||
});
|
||||
|
||||
els.dropZone.addEventListener('dragleave', () => {
|
||||
els.dropZone.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
els.dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
els.dropZone.classList.remove('drag-over');
|
||||
addFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
els.fileInput.addEventListener('change', (e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
function addFiles(fileList) {
|
||||
const incoming = Array.from(fileList);
|
||||
for (const f of incoming) {
|
||||
if (state.files.length >= MAX_FILES) {
|
||||
showError(`Максимум ${MAX_FILES} файлов`);
|
||||
break;
|
||||
}
|
||||
if (f.size > MAX_FILE_SIZE) {
|
||||
showError(`Файл "${f.name}" больше 20 МБ`);
|
||||
continue;
|
||||
}
|
||||
state.files.push(f);
|
||||
}
|
||||
renderFileList();
|
||||
}
|
||||
|
||||
function renderFileList() {
|
||||
els.fileList.innerHTML = '';
|
||||
state.files.forEach((f, i) => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `
|
||||
<span>📄 ${escapeHtml(f.name)} <span style="color:var(--text-dim)">(${formatSize(f.size)})</span></span>
|
||||
<button class="remove" data-i="${i}" title="Удалить">×</button>
|
||||
`;
|
||||
els.fileList.appendChild(li);
|
||||
});
|
||||
els.fileList.querySelectorAll('.remove').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.files.splice(Number(btn.dataset.i), 1);
|
||||
renderFileList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
// --- File parsing ---
|
||||
async function parseFile(file) {
|
||||
const ext = file.name.toLowerCase().split('.').pop();
|
||||
switch (ext) {
|
||||
case 'txt': case 'md': case 'csv': case 'json': case 'xml': case 'html': case 'rtf':
|
||||
return await file.text();
|
||||
case 'pdf':
|
||||
return await parsePdf(file);
|
||||
case 'docx': case 'doc':
|
||||
return await parseDocx(file);
|
||||
default:
|
||||
throw new Error(`Неподдерживаемый формат: .${ext}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function parsePdf(file) {
|
||||
const buf = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: buf }).promise;
|
||||
const parts = [];
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const content = await page.getTextContent();
|
||||
parts.push(content.items.map((it) => it.str).join(' '));
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
async function parseDocx(file) {
|
||||
const buf = await file.arrayBuffer();
|
||||
const result = await mammoth.extractRawText({ arrayBuffer: buf });
|
||||
return result.value;
|
||||
}
|
||||
|
||||
// --- Char counter ---
|
||||
els.textInput.addEventListener('input', () => {
|
||||
els.charCount.textContent = els.textInput.value.length.toLocaleString('ru');
|
||||
});
|
||||
|
||||
// --- Run analysis ---
|
||||
els.runBtn.addEventListener('click', runAnalysis);
|
||||
els.stopBtn.addEventListener('click', () => {
|
||||
if (state.abortController) state.abortController.abort();
|
||||
});
|
||||
|
||||
async function runAnalysis() {
|
||||
hideError();
|
||||
const apiKey = (localStorage.getItem('anthropic_api_key') || els.apiKey.value || '').trim();
|
||||
if (!apiKey.startsWith('sk-ant-')) {
|
||||
showError('Введи и сохрани API-ключ Anthropic (sk-ant-...)');
|
||||
return;
|
||||
}
|
||||
|
||||
const analysisType = document.querySelector('input[name="analysis"]:checked').value;
|
||||
const extraContext = els.extraContext.value.trim();
|
||||
|
||||
setRunning(true);
|
||||
setProgress('Чтение документов...');
|
||||
|
||||
let combinedText = '';
|
||||
try {
|
||||
for (const f of state.files) {
|
||||
setProgress(`Парсинг: ${f.name}`);
|
||||
const txt = await parseFile(f);
|
||||
combinedText += `\n\n===== ФАЙЛ: ${f.name} =====\n\n${txt}`;
|
||||
}
|
||||
const manual = els.textInput.value.trim();
|
||||
if (manual) combinedText += `\n\n===== ВСТАВЛЕННЫЙ ТЕКСТ =====\n\n${manual}`;
|
||||
combinedText = combinedText.trim();
|
||||
} catch (err) {
|
||||
setRunning(false);
|
||||
showError('Ошибка чтения файла: ' + err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!combinedText) {
|
||||
setRunning(false);
|
||||
showError('Добавь хотя бы один файл или вставь текст');
|
||||
return;
|
||||
}
|
||||
|
||||
if (combinedText.length > MAX_TEXT_CHARS * 3) {
|
||||
setRunning(false);
|
||||
showError(`Текст слишком длинный (${combinedText.length.toLocaleString('ru')} символов). Лимит для MVP: ~600 000.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const userPrompt = buildUserPrompt(analysisType, combinedText, extraContext);
|
||||
|
||||
setProgress('Запрос к Claude... (streaming)');
|
||||
els.result.innerHTML = '';
|
||||
state.lastResultMd = '';
|
||||
|
||||
try {
|
||||
await streamCompletion(apiKey, userPrompt);
|
||||
setProgress('Готово');
|
||||
hideProgress();
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
setProgress('Остановлено пользователем');
|
||||
} else {
|
||||
showError(err.message);
|
||||
}
|
||||
hideProgress();
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUserPrompt(type, doc, extra) {
|
||||
const taskInstruction = ANALYSIS_PROMPTS[type];
|
||||
let prompt = `${taskInstruction}\n\n`;
|
||||
if (extra) prompt += `ДОПОЛНИТЕЛЬНЫЙ КОНТЕКСТ ОТ ПОЛЬЗОВАТЕЛЯ:\n${extra}\n\n`;
|
||||
prompt += `ДОКУМЕНТ ДЛЯ АНАЛИЗА:\n\`\`\`\n${doc}\n\`\`\``;
|
||||
return prompt;
|
||||
}
|
||||
|
||||
async function streamCompletion(apiKey, userPrompt) {
|
||||
state.abortController = new AbortController();
|
||||
|
||||
const response = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
max_tokens: MAX_TOKENS,
|
||||
stream: true,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
}),
|
||||
signal: state.abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = response.statusText;
|
||||
try {
|
||||
const j = await response.json();
|
||||
detail = j.error?.message || JSON.stringify(j);
|
||||
} catch {}
|
||||
throw new Error(`API ${response.status}: ${detail}`);
|
||||
}
|
||||
|
||||
els.resultSection.classList.remove('hidden');
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
try {
|
||||
const evt = JSON.parse(payload);
|
||||
if (evt.type === 'content_block_delta' && evt.delta?.type === 'text_delta') {
|
||||
state.lastResultMd += evt.delta.text;
|
||||
renderMarkdown();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarkdown() {
|
||||
els.result.innerHTML = marked.parse(state.lastResultMd);
|
||||
}
|
||||
|
||||
// --- UI helpers ---
|
||||
function setRunning(running) {
|
||||
els.runBtn.disabled = running;
|
||||
els.stopBtn.disabled = !running;
|
||||
if (running) {
|
||||
els.progress.classList.remove('hidden');
|
||||
els.resultSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function setProgress(text) {
|
||||
els.progressText.textContent = text;
|
||||
}
|
||||
|
||||
function hideProgress() {
|
||||
els.progress.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
els.errorText.textContent = msg;
|
||||
els.errorSection.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
els.errorSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// --- Export ---
|
||||
els.copyBtn.addEventListener('click', async () => {
|
||||
if (!state.lastResultMd) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(state.lastResultMd);
|
||||
els.copyBtn.textContent = '✓ Скопировано';
|
||||
setTimeout(() => (els.copyBtn.textContent = '📋 Копировать'), 1500);
|
||||
} catch {
|
||||
showError('Не удалось скопировать');
|
||||
}
|
||||
});
|
||||
|
||||
els.downloadBtn.addEventListener('click', () => {
|
||||
if (!state.lastResultMd) return;
|
||||
const blob = new Blob([state.lastResultMd], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `analysis-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.md`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// --- Init ---
|
||||
loadKey();
|
||||
109
index.html
Normal file
109
index.html
Normal file
@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DocAgent — Анализ документов и оценка реализуемости</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="hero">
|
||||
<div class="container">
|
||||
<h1>DocAgent</h1>
|
||||
<p class="tagline">LLM-агент для анализа документов и оценки реализуемости требований</p>
|
||||
<ul class="features">
|
||||
<li>📄 PDF, DOCX, TXT, MD, JSON, XML, HTML, CSV, RTF</li>
|
||||
<li>🔍 6 режимов анализа</li>
|
||||
<li>📊 Извлечение требований с ID + оценка реализуемости</li>
|
||||
<li>⚡ Streaming-ответ, экспорт в Markdown</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="card" id="setup">
|
||||
<h2>1. API-ключ Anthropic</h2>
|
||||
<p class="hint">Ключ хранится только в твоём браузере (localStorage). На сервер не отправляется.</p>
|
||||
<div class="row">
|
||||
<input type="password" id="apiKey" placeholder="sk-ant-..." autocomplete="off" />
|
||||
<button id="saveKey" class="btn">Сохранить</button>
|
||||
<button id="clearKey" class="btn btn-ghost">Очистить</button>
|
||||
</div>
|
||||
<div id="keyStatus" class="status"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>2. Документ</h2>
|
||||
|
||||
<div id="dropZone" class="dropzone">
|
||||
<p>📎 Перетащи файлы сюда или <label for="fileInput" class="link">выбери</label></p>
|
||||
<input type="file" id="fileInput" multiple accept=".txt,.pdf,.docx,.doc,.md,.csv,.json,.xml,.html,.rtf" hidden />
|
||||
<p class="hint">До 5 файлов, каждый ≤ 20 МБ</p>
|
||||
</div>
|
||||
<ul id="fileList" class="file-list"></ul>
|
||||
|
||||
<details class="text-block">
|
||||
<summary>Или вставь текст вручную</summary>
|
||||
<textarea id="textInput" placeholder="Вставь текст документа..." maxlength="200000"></textarea>
|
||||
<div class="counter"><span id="charCount">0</span> / 200 000</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>3. Тип анализа</h2>
|
||||
<div class="analysis-types">
|
||||
<label><input type="radio" name="analysis" value="full" checked /> Полный анализ</label>
|
||||
<label><input type="radio" name="analysis" value="requirements" /> Извлечение требований</label>
|
||||
<label><input type="radio" name="analysis" value="feasibility" /> Оценка реализуемости</label>
|
||||
<label><input type="radio" name="analysis" value="risks" /> Анализ рисков</label>
|
||||
<label><input type="radio" name="analysis" value="summary" /> Краткое резюме</label>
|
||||
<label><input type="radio" name="analysis" value="recommendations" /> Рекомендации</label>
|
||||
</div>
|
||||
|
||||
<details class="extra-context">
|
||||
<summary>Дополнительный контекст (опционально)</summary>
|
||||
<textarea id="extraContext" placeholder="Уточнение задачи, состав команды, технологический стек, сроки..."></textarea>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="card actions">
|
||||
<button id="runBtn" class="btn btn-primary">▶ Запустить анализ</button>
|
||||
<button id="stopBtn" class="btn btn-ghost" disabled>⏹ Остановить</button>
|
||||
</section>
|
||||
|
||||
<section id="resultSection" class="card hidden">
|
||||
<div class="result-header">
|
||||
<h2>Результат</h2>
|
||||
<div class="result-actions">
|
||||
<button id="copyBtn" class="btn btn-ghost">📋 Копировать</button>
|
||||
<button id="downloadBtn" class="btn btn-ghost">⬇ Скачать .md</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="progress" class="progress hidden">
|
||||
<div class="spinner"></div>
|
||||
<span id="progressText">Обработка...</span>
|
||||
</div>
|
||||
<div id="result" class="result-content"></div>
|
||||
</section>
|
||||
|
||||
<section id="errorSection" class="card error hidden">
|
||||
<strong>Ошибка:</strong>
|
||||
<span id="errorText"></span>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>Powered by Claude · Browser-only · Данные не покидают твой браузер кроме API-вызова</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
</script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
412
styles.css
Normal file
412
styles.css
Normal file
@ -0,0 +1,412 @@
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--surface: #1a1f29;
|
||||
--surface-2: #232a36;
|
||||
--border: #2d3645;
|
||||
--text: #e6edf3;
|
||||
--text-dim: #9aa5b1;
|
||||
--primary: #5b8def;
|
||||
--primary-hover: #7aa3f5;
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--danger: #f85149;
|
||||
--radius: 10px;
|
||||
--maxw: 880px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: var(--maxw);
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 56px 0 32px;
|
||||
background: linear-gradient(180deg, #1a2233 0%, var(--bg) 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 38px;
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 17px;
|
||||
color: var(--text-dim);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.features li {
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 24px 0 48px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 22px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
input[type="password"],
|
||||
input[type="text"],
|
||||
textarea {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input[type="password"]:focus,
|
||||
input[type="text"]:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
input[type="password"],
|
||||
input[type="text"] {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 9px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #2a3142;
|
||||
border-color: #3a4458;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.status.ok { color: var(--success); }
|
||||
.status.err { color: var(--danger); }
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.dropzone.drag-over {
|
||||
border-color: var(--primary);
|
||||
background: rgba(91, 141, 239, 0.06);
|
||||
}
|
||||
|
||||
.dropzone p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.file-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--surface-2);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.file-list .remove {
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.file-list .remove:hover { color: var(--danger); }
|
||||
|
||||
.text-block,
|
||||
.extra-context {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.text-block summary,
|
||||
.extra-context summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
padding: 6px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.text-block[open] summary,
|
||||
.extra-context[open] summary {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.counter {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.analysis-types {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.analysis-types label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--surface-2);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.analysis-types label:hover {
|
||||
border-color: #3a4458;
|
||||
}
|
||||
|
||||
.analysis-types input[type="radio"] {
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.result-content {
|
||||
background: var(--surface-2);
|
||||
border-radius: 8px;
|
||||
padding: 18px 22px;
|
||||
overflow-x: auto;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.result-content h1,
|
||||
.result-content h2,
|
||||
.result-content h3,
|
||||
.result-content h4 {
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 0.6em;
|
||||
}
|
||||
|
||||
.result-content h2 { font-size: 20px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||
.result-content h3 { font-size: 17px; }
|
||||
|
||||
.result-content table {
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.result-content th,
|
||||
.result-content td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.result-content th {
|
||||
background: var(--surface);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-content code {
|
||||
background: var(--surface);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
.result-content pre {
|
||||
background: var(--surface);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.result-content pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.result-content blockquote {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 12px;
|
||||
margin: 12px 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(248, 81, 73, 0.08);
|
||||
border-color: rgba(248, 81, 73, 0.3);
|
||||
color: #ffb3ae;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
footer {
|
||||
padding: 20px 0 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero { padding: 36px 0 24px; }
|
||||
.hero h1 { font-size: 28px; }
|
||||
.card { padding: 16px; }
|
||||
}
|
||||
Reference in New Issue
Block a user