Три вещи, которых не хватало по сравнению с пультом DeepShim, — теперь здесь, и DeepShim можно закрывать. 1. Выключатель инструментов. Снят — приставка, дерево папки и напоминание про NEED/LIST/FIND не едут вовсе, круг ровно один: обычное "спросил по этим файлам, получил ответ". Замер на одной задаче: 229 символов вопроса против 1886. В консоли это флаг --no-tools. 2. Помощь с git: ряд всплывает, только если в папке нет репозитория или в .gitignore не хватает строк. Главная - backup/, куда ложатся прежние версии файлов перед записью. Перенесено из пульта DeepShim. 3. "Помнить прошлый ответ": галочка, по которой прошлый ответ уезжает вместе со следующим вопросом. Разговор из нескольких вопросов, которого раньше не было: стенограмма велась внутри одного вопроса и на этом всё. Попутно два дефекта, оба найдены при проверке: - Разбор флагов съедал аргумент после булева флага: "deepwork --apply вопрос" превращался в apply="вопрос", а вопрос пропадал. Молчало, пока булев флаг стоял последним. Та же грабля уже ловилась в ask.mjs; добавлен BOOL_FLAGS. - Блок-запрос вплотную к прозе не распознавался. DeepSeek регулярно пишет "Запрашиваю поиск константы.=== FIND: MAGIC ===" одной строкой, разбор требовал начала строки, и круг уходил впустую. Якорь начала снят, якорь конца оставлен: упоминание формата посреди фразы запросом не считается. От запросов внутри === FILE: === бережёт вырезание этих блоков, а не якорь. Проверено вживую: без инструментов - 229 символов, один круг, верный ответ; с инструментами - FIND распознан, два круга, верный ответ; git - repo:false и пять недостающих строк, после кнопки repo:true и missing пуст; память - "удвой это число" вернуло 80834 против бессмыслицы без галочки. --selftest дополнен двумя случаями на разбор запросов. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
10 KiB
HTML
209 lines
10 KiB
HTML
<!doctype html>
|
||
<meta charset="utf-8">
|
||
<title>deepwork</title>
|
||
<style>
|
||
:root { color-scheme: dark; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; height: 100vh; display: grid; grid-template-columns: 320px 1fr;
|
||
font: 14px/1.45 system-ui, "Segoe UI", sans-serif; background: #14161a; color: #e6e8ec; }
|
||
aside { border-right: 1px solid #262a31; display: flex; flex-direction: column; min-height: 0; }
|
||
main { display: flex; flex-direction: column; min-height: 0; }
|
||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: #8b93a1;
|
||
margin: 0; padding: 12px; border-bottom: 1px solid #262a31; }
|
||
.scroll { overflow: auto; flex: 1; padding: 8px 12px; min-height: 0; }
|
||
label.f { display: flex; gap: 8px; align-items: baseline; padding: 2px 0; cursor: pointer; }
|
||
label.f span { color: #6c7381; font-size: 12px; margin-left: auto; }
|
||
.row { display: flex; gap: 8px; align-items: center; padding: 10px 12px; border-top: 1px solid #262a31; }
|
||
.muted { color: #8b93a1; font-size: 12px; }
|
||
input[type=text], textarea, select { background: #1b1e24; color: #e6e8ec; border: 1px solid #333844;
|
||
border-radius: 6px; padding: 8px; font: inherit; width: 100%; }
|
||
textarea { resize: vertical; min-height: 84px; }
|
||
button { background: #262b34; color: #e6e8ec; border: 1px solid #3a414f; border-radius: 6px;
|
||
padding: 7px 14px; font: inherit; cursor: pointer; }
|
||
button:hover:not(:disabled) { background: #2f3540; }
|
||
button.primary { background: #3b6ea5; border-color: #4a83c0; }
|
||
button:disabled { opacity: .5; cursor: default; }
|
||
pre { background: #1b1e24; border: 1px solid #262a31; border-radius: 6px; padding: 10px;
|
||
white-space: pre-wrap; word-break: break-word; margin: 0 0 12px; }
|
||
.step { font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: #9fd08b; }
|
||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #7a4; display: inline-block; }
|
||
.dot.bad { background: #c55; }
|
||
.patch { border: 1px solid #333844; border-radius: 6px; margin-bottom: 10px; }
|
||
.patch > .row { border-top: none; }
|
||
.patch pre { margin: 0 12px 12px; max-height: 300px; overflow: auto; }
|
||
</style>
|
||
|
||
<aside>
|
||
<h2>Файлы <span class="muted" id="root"></span></h2>
|
||
<div class="row"><input type="text" id="filter" placeholder="фильтр по имени"></div>
|
||
<div class="scroll" id="tree">загружаю…</div>
|
||
<div class="row"><button id="none">Снять все</button><span class="muted" id="sel">ничего не выбрано</span></div>
|
||
<div class="row" id="gitrow" hidden>
|
||
<button id="gitinit">Инициализировать git</button>
|
||
<span class="muted" id="gitnote"></span>
|
||
</div>
|
||
</aside>
|
||
|
||
<main>
|
||
<h2><span class="dot" id="dot"></span> <span id="bar">проверяю мост…</span></h2>
|
||
<div style="padding:12px">
|
||
<textarea id="q" placeholder="Например: почему ответ приходит обрезанным? Дай исправленную функцию целиком."></textarea>
|
||
</div>
|
||
<div class="row">
|
||
<button class="primary" id="send">Спросить</button>
|
||
<label class="muted" title="Разрешить модели самой запрашивать файлы блоками NEED / LIST / FIND. Выключено — один круг и только отмеченные файлы.">
|
||
<input type="checkbox" id="tools" checked> инструменты</label>
|
||
<label class="muted" id="stepsbox">шагов <select id="steps" style="width:auto">
|
||
<option>2</option><option selected>4</option><option>6</option><option>8</option></select></label>
|
||
<label class="muted" title="Прошлый ответ уедет вместе со следующим вопросом.">
|
||
<input type="checkbox" id="keep"> помнить прошлый ответ</label>
|
||
<span class="muted" id="status"></span>
|
||
</div>
|
||
<div class="scroll" id="out"></div>
|
||
</main>
|
||
|
||
<script>
|
||
let files = [];
|
||
let lastAnswer = ''; // для галочки «помнить прошлый ответ»
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const esc = s => s.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||
const chosen = () => [...document.querySelectorAll('#tree input:checked')].map(i => i.value);
|
||
|
||
function renderTree() {
|
||
const needle = $('filter').value.toLowerCase();
|
||
const keep = new Set(chosen());
|
||
$('tree').innerHTML = files
|
||
.filter(f => !needle || f.path.toLowerCase().includes(needle))
|
||
.map(f => `<label class="f"><input type="checkbox" value="${esc(f.path)}"${keep.has(f.path) ? ' checked' : ''}>
|
||
${esc(f.path)}<span>${Math.max(1, Math.round(f.size / 1024))} КБ</span></label>`)
|
||
.join('') || '<div class="muted">ничего не подошло</div>';
|
||
count();
|
||
}
|
||
|
||
function count() {
|
||
const c = chosen();
|
||
const kb = files.filter(f => c.includes(f.path)).reduce((s, f) => s + f.size, 0) / 1024;
|
||
$('sel').textContent = c.length ? `${c.length} шт., ${Math.round(kb)} КБ` : 'ничего не выбрано';
|
||
}
|
||
|
||
$('tree').addEventListener('change', count);
|
||
|
||
// Без инструментов круг ровно один — выбор числа шагов теряет смысл.
|
||
$('tools').addEventListener('change', () => {
|
||
$('stepsbox').style.opacity = $('tools').checked ? '' : '.4';
|
||
$('steps').disabled = !$('tools').checked;
|
||
});
|
||
$('filter').addEventListener('input', renderTree);
|
||
$('none').onclick = () => { document.querySelectorAll('#tree input').forEach(i => i.checked = false); count(); };
|
||
|
||
fetch('/api/tree').then(r => r.json()).then(d => {
|
||
files = d.files; $('root').textContent = d.root; renderTree();
|
||
});
|
||
|
||
// Ряд с git всплывает, только когда он нужен: репозитория нет или в .gitignore
|
||
// не хватает строк. Главная из них — backup/: туда ложатся прежние версии
|
||
// файлов перед записью, и однажды они уедут в коммит.
|
||
async function gitState() {
|
||
try {
|
||
const g = await fetch('/api/git').then(r => r.json());
|
||
const need = !g.repo || g.missing.length;
|
||
$('gitrow').hidden = !need;
|
||
if (!need) return;
|
||
$('gitinit').textContent = g.repo ? 'Дополнить .gitignore' : 'Инициализировать git';
|
||
$('gitnote').textContent = g.repo ? `не хватает: ${g.missing.join(', ')}`
|
||
: 'папка не под git';
|
||
} catch { $('gitrow').hidden = true; }
|
||
}
|
||
gitState();
|
||
|
||
$('gitinit').onclick = async () => {
|
||
$('gitinit').disabled = true;
|
||
const r = await fetch('/api/git/init', { method: 'POST' }).then(x => x.json());
|
||
$('gitnote').textContent = r.ok
|
||
? (r.added.length ? `добавлено в .gitignore: ${r.added.join(', ')}` : 'всё уже на месте')
|
||
: `не вышло: ${r.reason}`;
|
||
$('gitinit').disabled = false;
|
||
if (r.ok) setTimeout(gitState, 1200);
|
||
};
|
||
|
||
async function status() {
|
||
try {
|
||
const s = await fetch('/api/status').then(r => r.json());
|
||
$('dot').className = 'dot' + (s.deepseek ? '' : ' bad');
|
||
$('bar').textContent = s.deepseek ? 'deepseek-api на месте, вкладка DeepSeek открыта'
|
||
: s.service ? 'deepseek-api отвечает, браузер ещё не поднят — встанет на первом вопросе'
|
||
: 'deepseek-api не запущен — запустите start.cmd в его папке';
|
||
} catch { $('dot').className = 'dot bad'; $('bar').textContent = 'пульт не отвечает'; }
|
||
}
|
||
status(); setInterval(status, 5000);
|
||
|
||
// Пока идёт прогон, страница не должна молчать: круг занимает до 4 минут,
|
||
// и без счётчика непонятно, работает ли что-то вообще.
|
||
let poll = null;
|
||
function watch() {
|
||
poll = setInterval(async () => {
|
||
const p = await fetch('/api/progress').then(r => r.json()).catch(() => null);
|
||
if (!p) return;
|
||
$('status').textContent = `идёт ${p.seconds} с`;
|
||
$('out').innerHTML = '<pre>' + p.steps.map(s => `<div class="step">${esc(s)}</div>`).join('') + '</pre>';
|
||
}, 2000);
|
||
}
|
||
|
||
$('send').onclick = async () => {
|
||
const question = $('q').value.trim();
|
||
if (!question) return;
|
||
$('send').disabled = true;
|
||
$('out').innerHTML = '<pre class="muted">отправляю…</pre>';
|
||
watch();
|
||
try {
|
||
const r = await fetch('/api/ask', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
question, files: chosen(), steps: +$('steps').value,
|
||
tools: $('tools').checked,
|
||
// Галочку между вопросами не сбрасываем: если человек ведёт разговор,
|
||
// он ведёт его несколькими вопросами подряд.
|
||
context: $('keep').checked ? lastAnswer : '',
|
||
}),
|
||
});
|
||
const d = await r.json();
|
||
clearInterval(poll);
|
||
if (d.error) { $('out').innerHTML = `<pre style="color:#e88">${esc(d.error)}</pre>`; return; }
|
||
render(d);
|
||
} catch (e) {
|
||
clearInterval(poll);
|
||
$('out').innerHTML = `<pre style="color:#e88">${esc(String(e))}</pre>`;
|
||
} finally { $('send').disabled = false; $('status').textContent = ''; }
|
||
};
|
||
|
||
function render(d) {
|
||
lastAnswer = d.answer || '';
|
||
const steps = d.steps.map(s => `<div class="step">${esc(s)}</div>`).join('');
|
||
const patches = d.patches.map((p, i) => `
|
||
<div class="patch">
|
||
<div class="row"><b>${esc(p.path)}</b>
|
||
<span class="muted">${p.content.split('\n').length} строк</span>
|
||
<button data-i="${i}" style="margin-left:auto">Записать</button>
|
||
<span class="muted" id="note${i}"></span></div>
|
||
<pre>${esc(p.content.slice(0, 4000))}${p.content.length > 4000 ? '\n…' : ''}</pre>
|
||
</div>`).join('');
|
||
|
||
$('out').innerHTML = `<pre>${steps}</pre>
|
||
<h2 style="border:none;padding:0 0 8px">Ответ</h2>
|
||
<pre>${esc(d.answer)}</pre>
|
||
${d.patches.length ? `<h2 style="border:none;padding:0 0 8px">Файлы в ответе</h2>${patches}` : ''}`;
|
||
|
||
$('out').querySelectorAll('.patch button').forEach(b => b.onclick = async () => {
|
||
const p = d.patches[+b.dataset.i];
|
||
b.disabled = true;
|
||
const r = await fetch('/api/apply', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: p.path, content: p.content }),
|
||
}).then(x => x.json());
|
||
document.getElementById('note' + b.dataset.i).textContent =
|
||
r.ok ? (r.backup ? `записан, копия: ${r.backup}` : 'записан (новый файл)') : `не записан: ${r.reason}`;
|
||
});
|
||
}
|
||
</script>
|