fix(pages): inline CSS and JS into index.html

Gitea Pages serves .css and .js with Content-Type: text/plain, which
causes Chrome to ignore the stylesheet (and warn on scripts) due to
strict MIME checking. Inlining both into a single index.html sidesteps
the issue. Removes styles.css and app.js (single source of truth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Baiken Zholdaskhan 2026-05-19 12:20:17 +05:00
parent fc1bc44b06
commit 0c1500e92f
3 changed files with 546 additions and 817 deletions

403
app.js
View File

@ -1,403 +0,0 @@
// 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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();

View File

@ -4,10 +4,189 @@
<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>
<style>
: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; }
}
</style>
</head>
<body>
<header class="hero">
@ -104,6 +283,371 @@
<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>
<script>
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: '' };
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'),
};
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';
});
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
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;
}
els.textInput.addEventListener('input', () => {
els.charCount.textContent = els.textInput.value.length.toLocaleString('ru');
});
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);
}
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'); }
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);
});
loadKey();
</script>
</body>
</html>

View File

@ -1,412 +0,0 @@
: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; }
}