Первая версия: инструменты и цикл для DeepSeek

DeepSeek через веб-чат видит только то, что человек положил в вопрос. Здесь
у него появляется обратное направление: он возвращает блок-запрос, программа
исполняет его и досылает результат следующим сообщением в тот же чат. Обычный
агентский цикл, только транспорт — текст.

Инструменты (только чтение, только внутри рабочей папки):
  === NEED: путь, путь ===   прислать файлы целиком
  === LIST: **/*.mjs ===     список файлов по маске
  === FIND: текст ===        где встречается, с номерами строк

Запуска команд намеренно нет: исполнять строки из ответа, пришедшего из
интернета, без белого списка и подтверждения нельзя.

Мост ollama_orchestro/ask.mjs не импортируется и не копируется — он разбирает
process.argv и исполняет команду на верхнем уровне, поэтому import немедленно
запустил бы её. Вызывается подпроцессом теми флагами, которые у него уже есть:
шаг 1 без --new (правка первого сообщения), шаги 2..N с --new, иначе DeepSeek
не помнит предыдущий шаг. Взамен orchestro не меняется ни на строку, а его
правки прилетают сюда даром.

Два урока, каждый стоил живого прогона и вписан в код:
- инструкция в начале сообщения не работает. 22 КБ файла между приставкой и
  вопросом — и блоки-запросы игнорируются. Помогает дословное напоминание
  вплотную к вопросу;
- дерево папки надо давать сразу. Без него DeepSeek угадывает имена файлов и
  тратит два круга по минуте на LIST.

Есть веб-пульт (start-deepwork.cmd, бросить папку): файлы галочками, журнал
шагов по ходу, запись правок с копией в backup/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alex Cube
2026-08-11 16:31:33 +03:00
co-authored by Claude Opus 5
commit 97eefb57c7
7 changed files with 1008 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
<!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>
</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">шагов <select id="steps" style="width:auto">
<option>2</option><option selected>4</option><option>6</option><option>8</option></select></label>
<span class="muted" id="status"></span>
</div>
<div class="scroll" id="out"></div>
</main>
<script>
let files = [];
const $ = id => document.getElementById(id);
const esc = s => s.replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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);
$('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();
});
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 открыта'
: s.browser ? 'браузер есть, но вкладки DeepSeek нет' : 'браузер с портом 9333 не найден — запустите start-browser.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 }),
});
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) {
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>