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>
160 lines
7.8 KiB
HTML
160 lines
7.8 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>
|
||
</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 => ({ '&': '&', '<': '<', '>': '>' }[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>
|