Первая версия: инструменты и цикл для 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:
@@ -0,0 +1,6 @@
|
||||
backup/
|
||||
config.json
|
||||
node_modules/
|
||||
*.log
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
@@ -0,0 +1,170 @@
|
||||
# deepwork
|
||||
|
||||
Цикл с инструментами поверх моста `ollama_orchestro/ask.mjs`.
|
||||
|
||||
DeepSeek через веб-чат видит только то, что положили ему в вопрос. Здесь у него
|
||||
появляется обратное направление: он возвращает блок-запрос, программа его
|
||||
исполняет и досылает результат следующим сообщением в тот же чат. Обычный
|
||||
агентский цикл, только транспорт — текст.
|
||||
|
||||
## Как пользоваться
|
||||
|
||||
Обычный путь — **пульт**: бросить папку проекта на `start-deepwork.cmd`.
|
||||
Откроется страница: файлы галочками, поле вопроса, ход работы построчно,
|
||||
кнопка «Записать» у каждого пришедшего файла.
|
||||
|
||||
Пульт открывается в браузере ПО УМОЛЧАНИЮ, а не в отладочном: там он занял бы
|
||||
вкладку, и мост перестал бы видеть DeepSeek. Порт 8787 — рядом с пультом
|
||||
orchestro (8777), оба могут работать разом.
|
||||
|
||||
Тот же цикл из консоли:
|
||||
|
||||
```
|
||||
cd c:\мой\проект
|
||||
node c:\#Projects\deepwork\deepwork.mjs "вопрос" --files a.js,b.js --apply
|
||||
node c:\#Projects\deepwork\deepwork.mjs ui тот же пульт вручную
|
||||
```
|
||||
|
||||
Работает в текущей папке: её файлы показывает, в неё же пишет правки.
|
||||
|
||||
## Инструменты
|
||||
|
||||
Только чтение и только внутри текущей папки:
|
||||
|
||||
| Блок в ответе | Что делает |
|
||||
|---|---|
|
||||
| `=== NEED: путь, путь ===` | прислать файлы целиком |
|
||||
| `=== LIST: **/*.mjs ===` | список файлов по маске |
|
||||
| `=== FIND: текст ===` | где встречается текст, с номерами строк |
|
||||
|
||||
Запуска команд (`RUN`) нет намеренно: исполнять строки из ответа, пришедшего
|
||||
из интернета, без белого списка и подтверждения нельзя.
|
||||
|
||||
Лимиты на результат шага: `NEED` — 200 000 символов суммарно, `FIND` — 100
|
||||
попаданий, `LIST` — 300 путей. Обрезанное помечается прямо в тексте.
|
||||
|
||||
## Как устроено
|
||||
|
||||
Мост **не импортируется и не копируется**. `ask.mjs` разбирает `process.argv`
|
||||
и исполняет команду на верхнем уровне — `import` немедленно запустил бы её.
|
||||
Поэтому мост вызывается подпроцессом теми флагами, которые у него уже есть:
|
||||
|
||||
```
|
||||
node ask.mjs ask --chat think --file step1.txt --out ans1.txt --quiet
|
||||
node ask.mjs ask --chat think --new --file step2.txt --out ans2.txt --quiet
|
||||
```
|
||||
|
||||
Шаг 1 идёт без `--new`: мост правит первое сообщение, и чат остаётся из одного
|
||||
обмена. Шаги 2..N — с `--new`, обычной отправкой: только так DeepSeek помнит
|
||||
предыдущий шаг. Цена — переподключение к браузеру на каждом шаге, 1–2 секунды.
|
||||
Взамен `ollama_orchestro` не меняется ни на строку, а его правки прилетают сюда
|
||||
даром.
|
||||
|
||||
Чат берётся тот же, что у orchestro (`think` из его `chats.json`). Своя роль не
|
||||
заводится: `chats.json` принадлежит мосту.
|
||||
|
||||
Цикл останавливается, когда запросов в ответе нет, когда кончились шаги или
|
||||
когда пришёл тот же самый запрос второй раз подряд (петля).
|
||||
|
||||
## Настройка
|
||||
|
||||
`config.json` (в репозиторий не попадает, образец — `config.example.json`):
|
||||
|
||||
```json
|
||||
{ "bridge": "C:/#Projects/ollama_orchestro/ask.mjs", "chat": "think", "steps": 4 }
|
||||
```
|
||||
|
||||
Без файла мост ищется рядом: `../ollama_orchestro/ask.mjs`.
|
||||
|
||||
## Проверка
|
||||
|
||||
```
|
||||
node deepwork.mjs --selftest внутренние проверки, ничего не отправляют
|
||||
node deepwork.mjs --check мост на месте, вкладка жива
|
||||
```
|
||||
|
||||
Браузер должен быть запущен `start-browser.cmd` из `ollama_orchestro` —
|
||||
с отладочным портом 9333.
|
||||
|
||||
## Что проверено
|
||||
|
||||
- `--selftest`: разбор запросов, отказ пути за пределы папки, маски `*` и `**`,
|
||||
снятие тройных кавычек и подписи языка. Строка вида `=== NEED: … ===`
|
||||
**внутри** блока `=== FILE: ===` за запрос не принимается — иначе правка
|
||||
файла с описанием формата запускала бы запрос сама.
|
||||
- Цикл целиком на заглушке вместо моста: приставка едет в первом сообщении,
|
||||
второй вызов уходит с `--new`, результаты `NEED` и `FIND` доезжают,
|
||||
отступы в записанном файле сохраняются, прежняя версия уходит в `backup/`.
|
||||
- Пропавший файл в `NEED` возвращает «файла нет» и цикл не роняет.
|
||||
- `--steps 1` отдаёт ответ с пометкой «шаги кончились», а не зависает.
|
||||
- Битый `config.json` — понятная ошибка, а не молчаливый откат к умолчаниям.
|
||||
|
||||
### Живые прогоны на DeepSeek, 11.08.2026
|
||||
|
||||
Задача одна и та же: из папки `ollama_orchestro` спросить, где решается, что
|
||||
ответ дописан до конца, дав в вопросе **только** `ui.html` — то есть заведомо
|
||||
не тот файл. Правильный ответ (`waitForAnswer` в `ask.mjs`) достижим только
|
||||
через инструменты.
|
||||
|
||||
| Прогон | Шагов | Что вышло |
|
||||
|---|---|---|
|
||||
| 1. приставка только в начале сообщения | 1 | **запросов нет.** «Точный код в серверном файле» и три догадки |
|
||||
| 2. + напоминание вплотную к вопросу | 4 | нашёл, но 2 круга ушли на угадывание имён файлов |
|
||||
| 3. + дерево папки в первом сообщении | **2** | запросил `ask.mjs` сразу, ответил по существу |
|
||||
|
||||
Два вывода, оба стоили живого прогона:
|
||||
|
||||
- **Инструкция в начале сообщения не работает.** Между приставкой и вопросом
|
||||
легли 22 КБ файла, и DeepSeek инструменты просто не заметил. Помогло
|
||||
дословное повторение перед самим вопросом (`TAIL_REMINDER`).
|
||||
- **Дерево папки надо давать сразу.** Без него DeepSeek угадывал имена
|
||||
(`server.js`, `bridge.js` — таких в проекте нет), потом просил `LIST *.js`
|
||||
(проект на `.mjs`, пусто), потом `LIST **/*`. Дерево — 600 символов против
|
||||
двух кругов по минуте.
|
||||
|
||||
### Пульт, 11.08.2026
|
||||
|
||||
Сквозной прогон через страницу: вопрос без единого приложенного файла —
|
||||
«какая функция решает, что ответ дописан?». 36 секунд, три шага. DeepSeek
|
||||
запросил пятью блоками `FIND` сразу (`waitForAnswer`, `streaming`, `done`,
|
||||
`stop_reason`, `finish_reason`), затем `NEED: ask.mjs`, и ответил точно:
|
||||
`waitForAnswer`, строка 675. Журнал шагов обновлялся на странице по ходу.
|
||||
|
||||
## Обрезанные ответы: причина найдена и устранена в мосте (11.08.2026)
|
||||
|
||||
В третьем прогоне ответ оборвался посреди блока кода на `// Бы`. Оказалось,
|
||||
это не «конец определён раньше времени» и не кнопка `Continue`.
|
||||
|
||||
**Браузер работал в фоне.** У неактивного окна страница не отрисовывается, а
|
||||
DeepSeek рисует блоки кода лениво: пока блок не нарисован, в DOM от него
|
||||
лежат первые две строки, а остального нет вовсе — его не достать ни
|
||||
`innerText`, ни `textContent`. Прокрутка не помогает (проверено проходом по
|
||||
всему ответу шагами: ни одного лишнего символа).
|
||||
|
||||
Помогает единственное — заставить браузер отрисовать страницу, и через CDP
|
||||
это делает `Page.captureScreenshot`. Замеры на живых ответах:
|
||||
|
||||
| | до отрисовки | после |
|
||||
|---|---|---|
|
||||
| ответ про TaskQueue | 2068 | 24 362 |
|
||||
| ответ про EventBus | 3136 | 30 817 |
|
||||
| ответ про Router (уже с правкой) | 258 | **32 045** |
|
||||
|
||||
Правка в `ollama_orchestro/ask.mjs`: `revealAnswer()` перед съёмом текста
|
||||
делает дешёвый скриншот (jpeg, качество 1) и перечитывает ответ, до трёх раз,
|
||||
пока текст растёт. Картинка выбрасывается.
|
||||
|
||||
Вдобавок `browser-launch.ps1` запускает браузер развёрнутым и с запретом
|
||||
фонового режима (`--disable-backgrounding-occluded-windows`,
|
||||
`--disable-renderer-backgrounding`, `--disable-background-timer-throttling`,
|
||||
`--disable-features=CalculateNativeWinOcclusion`). Это не замена скриншоту:
|
||||
свёрнутое окно Windows не рисует вообще.
|
||||
|
||||
Вторая причина обрезания, найденная там же, — кнопка **Continue**: DeepSeek
|
||||
режет длинный ответ по своему лимиту и предлагает дописать, а генерация при
|
||||
этом честно завершается. Теперь `waitForAnswer` жмёт её и ждёт дальше;
|
||||
продолжение уходит в тот же блок ответа. Сквозная проверка — ответ на 47 414
|
||||
символов с целыми кодом и тестами.
|
||||
|
||||
Обе правки в `ollama_orchestro`, deepwork получает их даром — мост вызывается
|
||||
подпроцессом.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Приставка к первому шагу цикла
|
||||
|
||||
Едет в самое начало первого сообщения, перед файлами и вопросом. Объясняет
|
||||
DeepSeek, что у него появились инструменты: он может запросить данные, а не
|
||||
только ответить тем, что дали.
|
||||
|
||||
Всё, что ниже разделителя, уходит в чат дословно.
|
||||
|
||||
---
|
||||
|
||||
ИНСТРУМЕНТЫ. Между тобой и рабочей папкой стоит программа. Если данных не
|
||||
хватает — не гадай и не проси человека прислать файл. Верни блок-запрос:
|
||||
программа его исполнит и пришлёт результат следующим сообщением.
|
||||
|
||||
=== NEED: путь/файл.js, другой/файл.css ===
|
||||
прислать содержимое этих файлов целиком
|
||||
|
||||
=== LIST: **/*.mjs ===
|
||||
список файлов по маске (`**` — любые папки, `*` — часть имени)
|
||||
|
||||
=== FIND: waitForAnswer ===
|
||||
строки, где встречается этот текст, с путями и номерами строк
|
||||
|
||||
Правила, соблюдать строго — блоки разбирает программа, не человек:
|
||||
|
||||
- блок пишется ОТДЕЛЬНОЙ строкой, целиком, без отступов и без кавычек;
|
||||
- один круг занимает до минуты, поэтому запрашивай ВСЁ нужное сразу —
|
||||
несколькими блоками в одном сообщении, а не по одному за раз;
|
||||
- пути — от корня рабочей папки, ровно в том виде, в каком ты их видел;
|
||||
- запросов нет — отвечай по существу и блоки не выдумывай;
|
||||
- получив данные, сразу отвечай на вопрос: не благодари, не пересказывай
|
||||
присланное и не описывай, что собираешься сделать;
|
||||
- шагов у цикла мало, растягивать нельзя. Хватает данных — отвечай.
|
||||
|
||||
Правки файлов возвращай блоками `=== FILE: путь ===`, как описано выше.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"bridge": "C:\\#Projects\\ollama_orchestro\\ask.mjs",
|
||||
"chat": "think",
|
||||
"steps": 4,
|
||||
"timeout": 240
|
||||
}
|
||||
+569
@@ -0,0 +1,569 @@
|
||||
#!/usr/bin/env node
|
||||
// deepwork — цикл с инструментами поверх моста ollama_orchestro/ask.mjs.
|
||||
//
|
||||
// DeepSeek через веб-чат видит только то, что ему положили в вопрос. Здесь он
|
||||
// получает обратное направление: возвращает блок-запрос (NEED/LIST/FIND),
|
||||
// программа его исполняет и досылает результат следующим сообщением в тот же
|
||||
// чат. Получается обычный агентский цикл, только транспорт — текст.
|
||||
//
|
||||
// Мост НЕ импортируется и НЕ копируется: ask.mjs разбирает process.argv и
|
||||
// исполняет команду на верхнем уровне, поэтому import немедленно запустил бы
|
||||
// её. Вызываем его подпроцессом теми флагами, которые у него уже есть.
|
||||
// ollama_orchestro при этом не меняется ни на строку.
|
||||
|
||||
import { readFile, writeFile, mkdir, copyFile, readdir, stat, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { resolve, join, dirname, relative, isAbsolute } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ------------------------------------------------------------------ аргументы
|
||||
|
||||
const rawArgs = process.argv.slice(2);
|
||||
const positional = [];
|
||||
const flags = {};
|
||||
for (let i = 0; i < rawArgs.length; i++) {
|
||||
const a = rawArgs[i];
|
||||
if (a.startsWith('--')) {
|
||||
const eq = a.indexOf('=');
|
||||
if (eq !== -1) { flags[a.slice(2, eq)] = a.slice(eq + 1); continue; }
|
||||
const next = rawArgs[i + 1];
|
||||
if (next !== undefined && !next.startsWith('--')) { flags[a.slice(2)] = next; i++; }
|
||||
else flags[a.slice(2)] = true;
|
||||
} else positional.push(a);
|
||||
}
|
||||
|
||||
const USAGE = `
|
||||
deepwork — задать DeepSeek вопрос и дать ему дочитать недостающее самому
|
||||
|
||||
node deepwork.mjs "вопрос" [--files a.js,b.js] [--steps 4] [--apply]
|
||||
node deepwork.mjs ui веб-пульт: файлы галочками, ход работы виден
|
||||
|
||||
Работает в ТЕКУЩЕЙ папке: её файлы показывает, в неё же пишет правки.
|
||||
|
||||
--files a.js,b.js что положить в вопрос сразу (через запятую)
|
||||
--steps N сколько кругов позволить (по умолчанию 4)
|
||||
--apply применить пришедшие блоки === FILE: === на диск
|
||||
(прежняя версия уезжает в backup/)
|
||||
--chat роль think (по умолчанию) | search
|
||||
--timeout сек максимум бездействия страницы, передаётся мосту
|
||||
--keep-temp не удалять временные файлы шагов
|
||||
--check проверить, что мост на месте и вкладка жива
|
||||
--selftest прогнать внутренние проверки, ничего не отправляя
|
||||
|
||||
Инструменты, которые может вернуть DeepSeek (только чтение, только внутри
|
||||
текущей папки):
|
||||
|
||||
=== NEED: путь, путь === прислать файлы целиком
|
||||
=== LIST: **/*.mjs === список файлов по маске
|
||||
=== FIND: текст === где встречается текст
|
||||
|
||||
Мост берётся из config.json (ключ bridge), по умолчанию —
|
||||
../ollama_orchestro/ask.mjs рядом с этой папкой.
|
||||
`.trim();
|
||||
|
||||
// ------------------------------------------------------------------ настройки
|
||||
|
||||
const CONFIG_DEFAULTS = {
|
||||
bridge: resolve(HERE, '..', 'ollama_orchestro', 'ask.mjs'),
|
||||
chat: 'think',
|
||||
steps: 4,
|
||||
timeout: 240,
|
||||
cdpPort: 9333, // отладочный порт браузера — только чтобы показать, жив ли мост
|
||||
uiPort: 8787, // не 8777: там живёт пульт orchestro, оба могут работать разом
|
||||
};
|
||||
|
||||
// Нет файла — работаем на умолчаниях, это норма. А вот битый JSON молчать не
|
||||
// должен: иначе правка в config.json не действует, и понять почему нельзя —
|
||||
// программа просто идёт по умолчанию к соседнему мосту.
|
||||
async function readConfig() {
|
||||
const file = join(HERE, 'config.json');
|
||||
let text;
|
||||
try { text = await readFile(file, 'utf8'); } catch { return { ...CONFIG_DEFAULTS }; }
|
||||
try { return { ...CONFIG_DEFAULTS, ...JSON.parse(text) }; }
|
||||
catch (e) { throw new Error(`config.json не разбирается: ${e.message}\nФайл: ${file}`); }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- работа с файлами
|
||||
|
||||
const SKIP_DIRS = new Set(['.git', 'node_modules', '.claude', 'dist', 'build', '__pycache__', '.venv', 'vendor', 'backup']);
|
||||
const SKIP_EXT = /\.(png|jpe?g|gif|webp|ico|pdf|zip|rar|7z|exe|dll|mp[34]|mov|woff2?|ttf|eot|lock|bak)$/i;
|
||||
|
||||
// Лимиты на результат инструмента: он уезжает в сообщение чата, а там предел
|
||||
// длины. Лучше отдать обрезанное с пометкой, чем не отдать ничего.
|
||||
const NEED_BUDGET = 200_000; // символов суммарно за шаг
|
||||
const FIND_HITS = 100;
|
||||
const LIST_PATHS = 300;
|
||||
|
||||
async function listFiles(root, rel = '', out = []) {
|
||||
for (const name of await readdir(join(root, rel))) {
|
||||
if (name.startsWith('.') && name !== '.gitignore') continue;
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
const r = rel ? `${rel}/${name}` : name;
|
||||
const st = await stat(join(root, r));
|
||||
if (st.isDirectory()) { await listFiles(root, r, out); continue; }
|
||||
if (SKIP_EXT.test(name)) continue;
|
||||
out.push({ path: r, size: st.size });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Путь обязан остаться внутри рабочей папки: ответ приходит из интернета,
|
||||
// доверять ему нельзя. Это касается и чтения, и записи.
|
||||
function resolveInside(root, rel) {
|
||||
if (!rel || isAbsolute(rel)) return null;
|
||||
const full = resolve(join(root, rel));
|
||||
if (relative(resolve(root), full).startsWith('..')) return null;
|
||||
return full;
|
||||
}
|
||||
|
||||
// Маска из LIST — не полноценный glob, а три подстановки: ** — любые папки,
|
||||
// * — часть имени, ? — один символ. Полный glob здесь не нужен, а зависимость
|
||||
// ради него — тем более.
|
||||
function maskToRe(mask) {
|
||||
const esc = mask.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||
const body = esc
|
||||
.replace(/\*\*\//g, '\u0000SLASHSTAR\u0000')
|
||||
.replace(/\*\*/g, '\u0000STARSTAR\u0000')
|
||||
.replace(/\*/g, '[^/]*')
|
||||
.replace(/\?/g, '[^/]')
|
||||
.replace(/\u0000SLASHSTAR\u0000/g, '(?:.*/)?')
|
||||
.replace(/\u0000STARSTAR\u0000/g, '.*');
|
||||
return new RegExp(`^${body}$`, 'i');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ разбор
|
||||
|
||||
const FILE_CLOSE = '=== END FILE ===';
|
||||
const FILE_BLOCK_RE = new RegExp(`^=== FILE:\\s*(.+?)\\s*===\\s*$([\\s\\S]*?)^${FILE_CLOSE}\\s*$`, 'gm');
|
||||
const LANG_RE = /^(text|plain|javascript|js|jsx|typescript|ts|tsx|json|html|css|scss|python|py|bash|sh|shell|cmd|bat|powershell|ps1|diff|patch|xml|yaml|yml|sql|java|kotlin|swift|c|cpp|cs|go|rust|rs|php|ruby|rb|ini|toml|markdown|md)$/i;
|
||||
|
||||
// Блоки === FILE: путь === … === END FILE ===. Содержимое обязано лежать в
|
||||
// тройных кавычках (иначе разметка страницы съедает отступы), а страница
|
||||
// показывает блок без самих кавычек, но с подписью языка отдельной строкой —
|
||||
// её тоже снимаем.
|
||||
function parsePatches(answer) {
|
||||
const out = [];
|
||||
const re = new RegExp(FILE_BLOCK_RE.source, 'gm');
|
||||
let m;
|
||||
while ((m = re.exec(String(answer || '')))) {
|
||||
const lines = m[2].replace(/^\n/, '').replace(/\n$/, '').split('\n');
|
||||
if (/^\s*```/.test(lines[0] ?? '')) {
|
||||
lines.shift();
|
||||
if (/^\s*```\s*$/.test(lines[lines.length - 1] ?? '')) lines.pop();
|
||||
}
|
||||
if (LANG_RE.test((lines[0] ?? '').trim())) lines.shift();
|
||||
out.push({ path: m[1].trim(), content: lines.join('\n') });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Запросы-однострочники. Тела и закрывающего блока у них нет — этим они и
|
||||
// отличаются от FILE. Блоки FILE вырезаются ПЕРЕД поиском: если DeepSeek
|
||||
// вернул файл, внутри которого написаны такие же строки (а этот проект как раз
|
||||
// про них), они не должны сойти за запрос.
|
||||
function parseRequests(answer) {
|
||||
const text = String(answer || '').replace(FILE_BLOCK_RE, '');
|
||||
const out = [];
|
||||
const re = /^=== (NEED|LIST|FIND):\s*(.+?)\s*===\s*$/gm;
|
||||
let m;
|
||||
while ((m = re.exec(text))) out.push({ kind: m[1], arg: m[2] });
|
||||
return out;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- исполнители
|
||||
|
||||
function fence(body) { return '```\n' + body + '\n```'; }
|
||||
|
||||
async function doNeed(root, arg, budget) {
|
||||
const parts = [];
|
||||
for (const rel of arg.split(',').map(s => s.trim().replace(/^["'`]|["'`]$/g, '')).filter(Boolean)) {
|
||||
const full = resolveInside(root, rel);
|
||||
if (!full) { parts.push(`=== RESULT: NEED ${rel} ===\n${fence('отказано: путь ведёт за пределы рабочей папки')}`); continue; }
|
||||
let text;
|
||||
try { text = await readFile(full, 'utf8'); }
|
||||
catch { parts.push(`=== RESULT: NEED ${rel} ===\n${fence('файла нет по такому пути')}`); continue; }
|
||||
if (text.length > budget.left) {
|
||||
text = text.slice(0, Math.max(budget.left, 0)) + `\n…обрезано, всего ${text.length} символов`;
|
||||
}
|
||||
budget.left -= text.length;
|
||||
parts.push(`=== FILE: ${rel} ===\n${fence(text)}\n${FILE_CLOSE}`);
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
async function doList(root, arg) {
|
||||
const re = maskToRe(arg.trim() || '**/*');
|
||||
const all = await listFiles(root);
|
||||
const hit = all.filter(f => re.test(f.path));
|
||||
const shown = hit.slice(0, LIST_PATHS);
|
||||
const body = shown.map(f => `${f.path} ${f.size} б`).join('\n')
|
||||
+ (hit.length > shown.length ? `\n…обрезано, всего ${hit.length}` : '')
|
||||
+ (hit.length ? '' : 'ничего не подошло под маску');
|
||||
return `=== RESULT: LIST ${arg} ===\n${fence(body)}`;
|
||||
}
|
||||
|
||||
async function doFind(root, arg) {
|
||||
const needle = arg.trim().toLowerCase();
|
||||
const hits = [];
|
||||
for (const f of await listFiles(root)) {
|
||||
if (f.size > 2_000_000) continue;
|
||||
let text;
|
||||
try { text = await readFile(join(root, f.path), 'utf8'); } catch { continue; }
|
||||
if (!text.toLowerCase().includes(needle)) continue;
|
||||
text.split('\n').forEach((line, i) => {
|
||||
if (hits.length >= FIND_HITS + 1) return;
|
||||
if (line.toLowerCase().includes(needle)) hits.push(`${f.path}:${i + 1}: ${line.trim().slice(0, 200)}`);
|
||||
});
|
||||
}
|
||||
const shown = hits.slice(0, FIND_HITS);
|
||||
const body = shown.join('\n')
|
||||
+ (hits.length > shown.length ? `\n…обрезано, показаны первые ${FIND_HITS}` : '')
|
||||
+ (hits.length ? '' : 'нигде не встречается');
|
||||
return `=== RESULT: FIND ${arg} ===\n${fence(body)}`;
|
||||
}
|
||||
|
||||
async function runRequests(root, requests, log) {
|
||||
const budget = { left: NEED_BUDGET };
|
||||
const parts = [];
|
||||
for (const r of requests) {
|
||||
log(` ${r.kind}: ${r.arg}`);
|
||||
if (r.kind === 'NEED') parts.push(await doNeed(root, r.arg, budget));
|
||||
else if (r.kind === 'LIST') parts.push(await doList(root, r.arg));
|
||||
else if (r.kind === 'FIND') parts.push(await doFind(root, r.arg));
|
||||
}
|
||||
return parts.filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ мост
|
||||
|
||||
// Один шаг: текст уходит через --file, ответ забирается через --out. Оба пути
|
||||
// абсолютные, поэтому рабочая папка подпроцесса роли не играет.
|
||||
// fresh=true — первое сообщение (мост правит его и чат остаётся из одного
|
||||
// обмена); fresh=false — обычная отправка, только так DeepSeek помнит шаг.
|
||||
async function callBridge(cfg, text, { fresh, temp, n, log }) {
|
||||
const qFile = join(temp, `step${n}-q.txt`);
|
||||
const aFile = join(temp, `step${n}-a.txt`);
|
||||
await writeFile(qFile, text, 'utf8');
|
||||
|
||||
const args = ['ask', '--chat', String(cfg.chat), '--file', qFile, '--out', aFile,
|
||||
'--timeout', String(cfg.timeout), '--quiet'];
|
||||
if (!fresh) args.push('--new');
|
||||
|
||||
log(`шаг ${n}: ${text.length} символов${fresh ? '' : ' (--new)'}, жду мост…`);
|
||||
const started = Date.now();
|
||||
const code = await new Promise((done, fail) => {
|
||||
const p = spawn(process.execPath, [cfg.bridge, ...args], { cwd: dirname(cfg.bridge) });
|
||||
let err = '';
|
||||
p.stdout.on('data', () => {});
|
||||
p.stderr.on('data', d => { err += d.toString(); });
|
||||
p.on('error', fail);
|
||||
p.on('close', c => { if (c !== 0) fail(new Error(err.trim() || `мост вышел с кодом ${c}`)); else done(c); });
|
||||
});
|
||||
void code;
|
||||
|
||||
const answer = await readFile(aFile, 'utf8');
|
||||
log(` ответ ${answer.length} символов за ${Math.round((Date.now() - started) / 1000)} с`);
|
||||
return answer;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ цикл
|
||||
|
||||
async function buildFirst(root, files, question) {
|
||||
let head = '';
|
||||
try {
|
||||
const raw = await readFile(join(HERE, 'agent-prompt.md'), 'utf8');
|
||||
head = (raw.split(/^---$/m)[1] ?? '').trim();
|
||||
} catch { /* нет файла — поедет без приставки, инструменты просто не всплывут */ }
|
||||
|
||||
// Дерево папки едет сразу. Первый живой прогон без него: DeepSeek угадывал
|
||||
// имена (server.js, bridge.js — таких нет), потом просил LIST *.js, потом
|
||||
// LIST **/* — два круга из четырёх ушли на выяснение, что вообще лежит в
|
||||
// папке. Дерево этого проекта — 600 символов против двух минут ожидания.
|
||||
const tree = (await listFiles(root)).slice(0, LIST_PATHS);
|
||||
const parts = [head, '',
|
||||
'=== RESULT: LIST **/* ===',
|
||||
fence(tree.map(f => `${f.path} ${f.size} б`).join('\n')),
|
||||
'',
|
||||
];
|
||||
for (const rel of files) {
|
||||
const full = resolveInside(root, rel);
|
||||
if (!full) throw new Error(`файл вне рабочей папки: ${rel}`);
|
||||
parts.push(`=== FILE: ${rel} ===`, fence(await readFile(full, 'utf8')), FILE_CLOSE, '');
|
||||
}
|
||||
// Напоминание повторяется ВПЛОТНУЮ к вопросу. Первый живой прогон: приставка
|
||||
// ушла в начало сообщения, между ней и вопросом легли 22 КБ файла, и DeepSeek
|
||||
// инструменты проигнорировал — сам написал «точный код в другом файле» и
|
||||
// выдал три догадки вместо запроса. Инструкция в хвосте весит больше.
|
||||
parts.push(TAIL_REMINDER, '', 'ВОПРОС:', question.trim());
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
const TAIL_REMINDER = [
|
||||
'НАПОМИНАНИЕ ПЕРЕД ОТВЕТОМ. Присланными файлами дело не ограничено: рабочая',
|
||||
'папка доступна тебе целиком. Если для ответа нужен код, которого ты не',
|
||||
'видел, — НЕ строй догадок и не пиши «точный код в другом файле». Верни',
|
||||
'блок-запрос, он будет исполнен, и следующим сообщением ты получишь данные:',
|
||||
'',
|
||||
'=== NEED: путь/файл.js === прислать файл целиком',
|
||||
'=== LIST: **/*.mjs === список файлов по маске',
|
||||
'=== FIND: имяФункции === где встречается текст, с номерами строк',
|
||||
'',
|
||||
'Не знаешь пути — начни с LIST или FIND. Запрашивай всё нужное сразу, одним',
|
||||
'сообщением. Догадка вместо запроса считается неверным ответом.',
|
||||
].join('\n');
|
||||
|
||||
const FOLLOW_HEAD = [
|
||||
'РЕЗУЛЬТАТ ЗАПРОСА. Данные ниже. Отвечай по существу вопроса из первого',
|
||||
'сообщения; не благодари и не пересказывай присланное. Если данных всё ещё',
|
||||
'не хватает — верни новый блок-запрос, иначе отвечай. Правки файлов —',
|
||||
'блоками === FILE: путь === с содержимым в тройных кавычках.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
async function runAgent(cfg, root, question, files, log) {
|
||||
const temp = join(tmpdir(), `deepwork-${process.pid}`);
|
||||
await mkdir(temp, { recursive: true });
|
||||
|
||||
let text = await buildFirst(root, files, question);
|
||||
let answer = '';
|
||||
let lastSig = '';
|
||||
|
||||
for (let n = 1; n <= cfg.steps; n++) {
|
||||
answer = await callBridge(cfg, text, { fresh: n === 1, temp, n, log });
|
||||
const requests = parseRequests(answer);
|
||||
if (!requests.length) { log(`шаг ${n}: запросов нет, готово`); break; }
|
||||
|
||||
// Один и тот же запрос второй раз подряд — петля: данные мы уже присылали,
|
||||
// значит DeepSeek их не принял и повторит это бесконечно.
|
||||
const sig = requests.map(r => `${r.kind}:${r.arg}`).join('|');
|
||||
if (sig === lastSig) { log(`шаг ${n}: тот же запрос повторно — петля, выхожу`); break; }
|
||||
lastSig = sig;
|
||||
|
||||
if (n === cfg.steps) { log(`шаг ${n}: шаги кончились, а запросы ещё идут`); break; }
|
||||
|
||||
log(`шаг ${n}: запрошено ${requests.length}`);
|
||||
const result = await runRequests(root, requests, log);
|
||||
text = FOLLOW_HEAD + result;
|
||||
}
|
||||
|
||||
if (!flags['keep-temp']) await rm(temp, { recursive: true, force: true }).catch(() => {});
|
||||
else log(`временные файлы: ${temp}`);
|
||||
return answer;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- запись правок
|
||||
|
||||
async function applyPatch(root, rel, content) {
|
||||
const full = resolveInside(root, rel);
|
||||
if (!full) return { ok: false, reason: 'путь ведёт за пределы рабочей папки' };
|
||||
let backup = null;
|
||||
try {
|
||||
await readFile(full, 'utf8');
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
||||
backup = join('backup', `${rel}.bak-${stamp}`);
|
||||
await mkdir(dirname(join(root, backup)), { recursive: true });
|
||||
await copyFile(full, join(root, backup));
|
||||
} catch { /* новый файл, копировать нечего */ }
|
||||
await mkdir(dirname(full), { recursive: true });
|
||||
await writeFile(full, content, 'utf8');
|
||||
return { ok: true, backup };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ пульт
|
||||
|
||||
// Ход работы копится здесь, а страница забирает его опросом раз в 2 секунды.
|
||||
// Стримить нечего: строк за прогон десяток, а опрос — тот же приём, которым
|
||||
// пульт orchestro следит за мостом.
|
||||
const progress = { steps: [], busy: false, started: 0 };
|
||||
|
||||
async function serve(cfg) {
|
||||
const http = await import('node:http');
|
||||
const root = process.cwd();
|
||||
const port = Number.parseInt(flags.port, 10) || cfg.uiPort;
|
||||
|
||||
const json = (res, code, data) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
// Куски копим Buffer'ами и склеиваем один раз: складывание строками рвёт
|
||||
// многобайтовый символ на границе, и русский вопрос уезжает крякозябрами.
|
||||
const readBody = req => new Promise((ok, bad) => {
|
||||
const chunks = [];
|
||||
req.on('data', c => chunks.push(c));
|
||||
req.on('error', bad);
|
||||
req.on('end', () => {
|
||||
try { ok(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); } catch (e) { bad(e); }
|
||||
});
|
||||
});
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
return res.end(await readFile(join(HERE, 'ui.html'), 'utf8'));
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/tree')
|
||||
return json(res, 200, { root, files: await listFiles(root) });
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/status') {
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${cfg.cdpPort}/json/list`, { signal: AbortSignal.timeout(1500) });
|
||||
const pages = (await r.json()).filter(t => t.type === 'page');
|
||||
return json(res, 200, { browser: true, deepseek: pages.some(t => (t.url || '').includes('chat.deepseek.com')) });
|
||||
} catch { return json(res, 200, { browser: false, deepseek: false }); }
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/progress')
|
||||
return json(res, 200, { ...progress, seconds: progress.busy ? Math.round((Date.now() - progress.started) / 1000) : 0 });
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/ask') {
|
||||
if (progress.busy) return json(res, 409, { error: 'предыдущий вопрос ещё в работе' });
|
||||
const body = await readBody(req);
|
||||
if (!String(body.question || '').trim()) return json(res, 400, { error: 'пустой вопрос' });
|
||||
|
||||
progress.busy = true; progress.steps = []; progress.started = Date.now();
|
||||
try {
|
||||
const run = { ...cfg, steps: Number.parseInt(body.steps, 10) || cfg.steps };
|
||||
const answer = await runAgent(run, root, String(body.question), body.files || [],
|
||||
m => progress.steps.push(m));
|
||||
return json(res, 200, { answer, steps: progress.steps, patches: parsePatches(answer) });
|
||||
} finally { progress.busy = false; }
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/apply') {
|
||||
const { path: rel, content } = await readBody(req);
|
||||
return json(res, 200, await applyPatch(root, rel, content));
|
||||
}
|
||||
|
||||
res.writeHead(404); res.end('нет такой страницы');
|
||||
} catch (e) { json(res, 500, { error: String(e && e.message || e) }); }
|
||||
});
|
||||
|
||||
// Занятый порт не должен ронять запуск: прошлый пульт мог остаться висеть.
|
||||
const listen = p => new Promise((ok, bad) => {
|
||||
const onErr = e => (e.code === 'EADDRINUSE' ? ok(null) : bad(e));
|
||||
server.once('error', onErr);
|
||||
server.listen(p, '127.0.0.1', () => { server.off('error', onErr); ok(p); });
|
||||
});
|
||||
let actual = null;
|
||||
for (let p = port; p < port + 20 && actual === null; p++) actual = await listen(p);
|
||||
if (actual === null) throw new Error(`свободный порт не найден в диапазоне ${port}..${port + 19}`);
|
||||
|
||||
console.log(`пульт: http://127.0.0.1:${actual}`);
|
||||
console.log(`папка: ${root}`);
|
||||
console.log('остановить — Ctrl+C');
|
||||
|
||||
// Открываем СТРОГО в браузере по умолчанию. В отладочный браузер нельзя:
|
||||
// пульт займёт там вкладку, и мост перестанет находить chat.deepseek.com.
|
||||
if (!flags['no-open']) {
|
||||
const { exec } = await import('node:child_process');
|
||||
exec(`start "" "http://127.0.0.1:${actual}"`, { shell: 'cmd.exe' }, () => {});
|
||||
}
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- самопроверка
|
||||
|
||||
async function selftest() {
|
||||
const { strict: assert } = await import('node:assert');
|
||||
const root = process.cwd();
|
||||
|
||||
// Запросы разбираются, а такие же строки ВНУТРИ блока файла — нет.
|
||||
const sample = [
|
||||
'Не хватает данных.',
|
||||
'=== NEED: ask.mjs, ui.html ===',
|
||||
'=== LIST: **/*.mjs ===',
|
||||
'=== FIND: waitForAnswer ===',
|
||||
'=== FILE: agent-prompt.md ===',
|
||||
'```',
|
||||
'text',
|
||||
'=== NEED: это строка внутри файла ===',
|
||||
' отступ сохраняется',
|
||||
'```',
|
||||
FILE_CLOSE,
|
||||
].join('\n');
|
||||
|
||||
const req = parseRequests(sample);
|
||||
assert.deepEqual(req.map(r => r.kind), ['NEED', 'LIST', 'FIND']);
|
||||
assert.equal(req[0].arg, 'ask.mjs, ui.html');
|
||||
|
||||
const pat = parsePatches(sample);
|
||||
assert.equal(pat.length, 1);
|
||||
assert.equal(pat[0].path, 'agent-prompt.md');
|
||||
// Тройные кавычки и подпись языка сняты, отступ и строка-обманка целы.
|
||||
assert.equal(pat[0].content, '=== NEED: это строка внутри файла ===\n отступ сохраняется');
|
||||
|
||||
// Выход за пределы папки отвергается, свои пути — нет.
|
||||
assert.equal(resolveInside(root, '../../windows/system32/drivers/etc/hosts'), null);
|
||||
assert.equal(resolveInside(root, 'C:\\windows\\win.ini'), null);
|
||||
assert.ok(resolveInside(root, 'sub/dir/file.js'));
|
||||
|
||||
// Маска: * не перепрыгивает через папку, ** перепрыгивает.
|
||||
assert.ok(!maskToRe('*.mjs').test('sub/x.mjs'));
|
||||
assert.ok(maskToRe('*.mjs').test('x.mjs'));
|
||||
assert.ok(maskToRe('**/*.mjs').test('sub/dir/x.mjs'));
|
||||
assert.ok(maskToRe('**/*.mjs').test('x.mjs'));
|
||||
assert.ok(!maskToRe('**/*.mjs').test('x.md'));
|
||||
|
||||
console.log('самопроверка пройдена');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ запуск
|
||||
|
||||
const cfg = await readConfig();
|
||||
cfg.steps = Number.parseInt(flags.steps, 10) || cfg.steps;
|
||||
cfg.timeout = Number.parseInt(flags.timeout, 10) || cfg.timeout;
|
||||
if (flags.chat) cfg.chat = String(flags.chat);
|
||||
|
||||
if (flags.selftest) { await selftest(); process.exit(0); }
|
||||
|
||||
if (!existsSync(cfg.bridge)) {
|
||||
console.error(`Мост не найден: ${cfg.bridge}`);
|
||||
console.error('Укажите путь к ask.mjs в config.json (образец — config.example.json).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (flags.ui || positional[0] === 'ui') {
|
||||
await serve(cfg);
|
||||
} else if (flags.check) {
|
||||
console.log(`мост: ${cfg.bridge}`);
|
||||
const p = spawn(process.execPath, [cfg.bridge, 'probe'], { cwd: dirname(cfg.bridge), stdio: 'inherit' });
|
||||
p.on('close', c => process.exit(c));
|
||||
} else {
|
||||
const question = positional.join(' ');
|
||||
if (!question.trim()) { console.log(USAGE); process.exit(question ? 1 : 0); }
|
||||
|
||||
const root = process.cwd();
|
||||
const files = String(flags.files || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const log = (...a) => console.log(...a);
|
||||
|
||||
try {
|
||||
console.log(`папка: ${root}`);
|
||||
const answer = await runAgent(cfg, root, question, files, log);
|
||||
console.log('\n----- ответ DeepSeek -----');
|
||||
console.log(answer);
|
||||
|
||||
const patches = parsePatches(answer);
|
||||
if (patches.length) {
|
||||
console.log(`\n----- файлы в ответе: ${patches.length} -----`);
|
||||
for (const p of patches) console.log(` ${p.path} ${p.content.split('\n').length} строк`);
|
||||
if (flags.apply) {
|
||||
for (const p of patches) {
|
||||
const r = await applyPatch(root, p.path, p.content);
|
||||
console.log(r.ok ? `записан ${p.path}${r.backup ? ` (копия: ${r.backup})` : ' (новый)'}`
|
||||
: `пропущен ${p.path}: ${r.reason}`);
|
||||
}
|
||||
} else console.log(' не записаны: нужен флаг --apply');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Ошибка: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
REM ============================================================
|
||||
REM deepwork web console. Drag and drop a project folder onto
|
||||
REM this file: DeepSeek gets tools (NEED / LIST / FIND) and can
|
||||
REM read that folder himself, step by step.
|
||||
REM ASCII only on purpose - cmd.exe mangles UTF-8 Cyrillic.
|
||||
REM ============================================================
|
||||
|
||||
echo ========================================
|
||||
echo deepwork - DeepSeek with tools
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM %~dp0 ALWAYS ends with a backslash. A bare "%VAR%" argument built from it
|
||||
REM would escape its own closing quote - that bug already ate a flag once in
|
||||
REM ollama_orchestro. Keep the trimmed copy for whole-path arguments.
|
||||
set "DW_DIR=%~dp0"
|
||||
set "DW_ROOT=%DW_DIR:~0,-1%"
|
||||
|
||||
if "%~1"=="" (
|
||||
echo [ERROR] No project folder specified!
|
||||
echo Drag and drop a project folder onto this file.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%~1" (
|
||||
echo [ERROR] Folder "%~1" does not exist!
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Project folder: %~1
|
||||
|
||||
where node >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Node.js not found in PATH!
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Node.js found
|
||||
|
||||
powershell -NoProfile -Command "try { Invoke-RestMethod -Uri 'http://127.0.0.1:9333/json/version' -TimeoutSec 2 | Out-Null; exit 0 } catch { exit 1 }" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [WARN] Bridge is NOT available on port 9333.
|
||||
echo Run start-browser.cmd in ollama_orchestro, then log in.
|
||||
echo The console will start anyway and show the same warning.
|
||||
echo.
|
||||
)
|
||||
|
||||
cd /d "%~1"
|
||||
|
||||
echo.
|
||||
echo Open the printed address in your DEFAULT browser, not the debug one.
|
||||
echo Ctrl+C here stops it.
|
||||
echo.
|
||||
node "%DW_DIR%deepwork.mjs" ui
|
||||
|
||||
echo.
|
||||
pause
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>' }[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>
|
||||
Reference in New Issue
Block a user