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:
parent
fc1bc44b06
commit
0c1500e92f
403
app.js
403
app.js
@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||||
548
index.html
548
index.html
@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
|
||||
412
styles.css
412
styles.css
@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user