DeepSeek через deepseek-api; память шагов переехала из чата в стенограмму

Транспорт: был дочерний процесс ask.mjs из ollama_orchestro с обменом через
временные файлы, стал POST /api/chat к сервису deepseek-api. Он держит вкладку
браузера и отдаёт наружу протокол Ollama, поэтому здесь про браузер больше
не знают: ушли проверка существования моста, временные файлы шагов вместе
с флагом --keep-temp и четыре осиротевших импорта.

Главное не в транспорте. Раньше шаги 2..N уходили с флагом --new, и ход
разговора помнил сам чат DeepSeek. Сервис памяти не имеет принципиально:
каждый запрос он кладёт в чат целиком, затирая прошлый. Поэтому стенограмму
ведём здесь — growTranscript подклеивает к отправленному тексту прошлый ответ
модели между метками === ТВОЙ ПРЕДЫДУЩИЙ ОТВЕТ === и === END ===, и следующим
шагом уезжает всё сразу.

Так надёжнее, чем было: видно в одном месте, что именно уедет модели, а чужой
потребитель, вклинившийся между нашими шагами, ничего не портит — мы всё
приносим заново. Плата — вопрос растёт на длину прошлого ответа.

--check теперь различает три состояния: сервис отвечает и браузер поднят;
сервис жив, но браузера ещё нет; сервис не запущен. Плашка в ui.html тоже.

Настройки: bridge/chat/cdpPort заменены на deepseekHost и deepseekModel.

Проверено живым многошаговым прогоном: шаг 1 — 2094 символа, ответ
NEED: deepwork.mjs; шаг 2 — 28 447 символов стенограммы, ответ дословно верный
и по вопросу из ПЕРВОГО сообщения, то есть память шагов работает. Два шага
за 17 с. --selftest пройден, --check зелёный.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alex Cube
2026-08-26 13:40:09 +03:00
co-authored by Claude Opus 5
parent 3586e32a89
commit 44f07c8af9
4 changed files with 126 additions and 89 deletions
+34 -21
View File
@@ -1,6 +1,6 @@
# deepwork
Цикл с инструментами поверх моста `ollama_orchestro/ask.mjs`.
Цикл с инструментами поверх сервиса [deepseek-api](https://git.08h.ru/alex/deepseek-api).
DeepSeek через веб-чат видит только то, что положили ему в вопрос. Здесь у него
появляется обратное направление: он возвращает блок-запрос, программа его
@@ -45,23 +45,21 @@ node c:\#Projects\deepwork\deepwork.mjs ui тот же пульт вру
## Как устроено
Мост **не импортируется и не копируется**. `ask.mjs` разбирает `process.argv`
и исполняет команду на верхнем уровне — `import` немедленно запустил бы её.
Поэтому мост вызывается подпроцессом теми флагами, которые у него уже есть:
У веб-версии DeepSeek нет API — только вкладка в браузере. Разговором с ней
занимается отдельный сервис `deepseek-api`, а нам показывает протокол Ollama.
Здесь про браузер не знают вовсе: обычный `POST /api/chat`.
```
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
```
**Разговор помним мы, а не DeepSeek.** Сервис без памяти: каждый запрос он
кладёт в чат целиком, затирая прошлый. Поэтому стенограмма растёт здесь —
`growTranscript` подклеивает к отправленному тексту прошлый ответ модели
(между метками `=== ТВОЙ ПРЕДЫДУЩИЙ ОТВЕТ ===` и `=== END ===`) и результаты
запросов, и следующим шагом уезжает всё сразу.
Шаг 1 идёт без `--new`: мост правит первое сообщение, и чат остаётся из одного
обмена. Шаги 2..N — с `--new`, обычной отправкой: только так DeepSeek помнит
предыдущий шаг. Цена — переподключение к браузеру на каждом шаге, 1–2 секунды.
Взамен `ollama_orchestro` не меняется ни на строку, а его правки прилетают сюда
даром.
Чат берётся тот же, что у orchestro (`think` из его `chats.json`). Своя роль не
заводится: `chats.json` принадлежит мосту.
Раньше эту роль играл флаг `--new` у моста, и помнил всё сам чат. Своя
стенограмма надёжнее: видно в одном месте, что именно уедет модели, а чужой
потребитель, вклинившийся между нашими шагами, ничего не портит — мы всё
приносим заново. Плата — вопрос растёт на длину прошлого ответа; при `steps: 4`
это единицы килобайт против 22 КБ первого сообщения.
Цикл останавливается, когда запросов в ответе нет, когда кончились шаги или
когда пришёл тот же самый запрос второй раз подряд (петля).
@@ -71,20 +69,35 @@ node ask.mjs ask --chat think --new --file step2.txt --out ans2.txt --quiet
`config.json` (в репозиторий не попадает, образец — `config.example.json`):
```json
{ "bridge": "C:/#Projects/ollama_orchestro/ask.mjs", "chat": "think", "steps": 4 }
{ "deepseekHost": "http://localhost:11500", "deepseekModel": "deepseek:think", "steps": 4 }
```
Без файла мост ищется рядом: `../ollama_orchestro/ask.mjs`.
Без файла берётся `http://localhost:11500`.
## Проверка
```
node deepwork.mjs --selftest внутренние проверки, ничего не отправляют
node deepwork.mjs --check мост на месте, вкладка жива
node deepwork.mjs --check сервис на месте, вкладка жива
```
Браузер должен быть запущен `start-browser.cmd` из `ollama_orchestro`
с отладочным портом 9333.
`--check` различает три состояния: сервис отвечает и браузер поднят; сервис
жив, но браузера ещё нет (встанет на первом же вопросе); сервис не запущен.
Запускается сервис своим `start.cmd` в папке `deepseek-api` — браузер он
поднимает сам.
## Переезд на deepseek-api (26.08.2026)
Транспорт сменился: был дочерний процесс `ask.mjs` из `ollama_orchestro`
с обменом через временные файлы, стал `POST /api/chat` к сервису
`deepseek-api`. Заодно ушли временные файлы шагов (и флаг `--keep-temp`),
проверка существования моста и запуск браузера — всё это теперь не наша забота.
Память шагов переехала из чата DeepSeek в `growTranscript` (см. «Как устроено»).
Проверено живым прогоном: шаг 1 — 2094 символа, ответ `NEED: deepwork.mjs`;
шаг 2 — 28 447 символов стенограммы, ответ дословно верный и по вопросу
из ПЕРВОГО сообщения. Два шага за 17 с.
## Что проверено
+2 -2
View File
@@ -1,6 +1,6 @@
{
"bridge": "C:\\#Projects\\ollama_orchestro\\ask.mjs",
"chat": "think",
"deepseekHost": "http://localhost:11500",
"deepseekModel": "deepseek:think",
"steps": 4,
"timeout": 240
}
+87 -64
View File
@@ -1,22 +1,18 @@
#!/usr/bin/env node
// deepwork — цикл с инструментами поверх моста ollama_orchestro/ask.mjs.
// deepwork — цикл с инструментами поверх сервиса deepseek-api.
//
// DeepSeek через веб-чат видит только то, что ему положили в вопрос. Здесь он
// получает обратное направление: возвращает блок-запрос (NEED/LIST/FIND),
// программа его исполняет и досылает результат следующим сообщением в тот же
// чат. Получается обычный агентский цикл, только транспорт — текст.
//
// Мост НЕ импортируется и НЕ копируется: ask.mjs разбирает process.argv и
// исполняет команду на верхнем уровне, поэтому import немедленно запустил бы
// её. Вызываем его подпроцессом теми флагами, которые у него уже есть.
// ollama_orchestro при этом не меняется ни на строку.
// С DeepSeek говорим по HTTP: у веб-версии API нет, но рядом крутится сервис
// deepseek-api, который держит вкладку браузера и отдаёт наружу протокол
// Ollama. Здесь про браузер не знают вовсе.
import { readFile, writeFile, mkdir, copyFile, readdir, stat, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { readFile, writeFile, mkdir, copyFile, readdir, stat } from 'node:fs/promises';
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));
@@ -50,7 +46,6 @@ deepwork — задать DeepSeek вопрос и дать ему дочита
(прежняя версия уезжает в backup/)
--chat роль think (по умолчанию) | search
--timeout сек максимум бездействия страницы, передаётся мосту
--keep-temp не удалять временные файлы шагов
--check проверить, что мост на месте и вкладка жива
--selftest прогнать внутренние проверки, ничего не отправляя
@@ -61,18 +56,21 @@ deepwork — задать DeepSeek вопрос и дать ему дочита
=== LIST: **/*.mjs === список файлов по маске
=== FIND: текст === где встречается текст
Мост берётся из config.json (ключ bridge), по умолчанию —
../ollama_orchestro/ask.mjs рядом с этой папкой.
Адрес сервиса берётся из config.json (ключ deepseekHost), по умолчанию —
http://localhost:11500. Сервис запускается своим start.cmd.
`.trim();
// ------------------------------------------------------------------ настройки
const CONFIG_DEFAULTS = {
bridge: resolve(HERE, '..', 'ollama_orchestro', 'ask.mjs'),
chat: 'think',
// У веб-версии DeepSeek нет API. Рядом крутится отдельный сервис deepseek-api
// (git.08h.ru/alex/deepseek-api): он держит вкладку браузера и отдаёт наружу
// протокол Ollama. Запускается своим start.cmd, браузер поднимает сам.
deepseekHost: 'http://localhost:11500',
deepseekModel: 'deepseek:think', // think | search | vision — режим чата
steps: 4,
timeout: 240,
cdpPort: 9333, // отладочный порт браузера — только чтобы показать, жив ли мост
timeout: 240, // секунд на один шаг; сверх этого сервису дают ещё две минуты
// на очередь: вкладка одна на всех потребителей
uiPort: 8787, // не 8777: там живёт пульт orchestro, оба могут работать разом
};
@@ -238,42 +236,50 @@ async function runRequests(root, requests, log) {
return parts.filter(Boolean).join('\n\n');
}
// ------------------------------------------------------------------ мост
// ------------------------------------------------------------------ DeepSeek
// Один шаг: текст уходит через --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)'}, жду мост…`);
// Один шаг: вопрос уходит по HTTP, ответ приходит там же. Раньше здесь был
// дочерний процесс с обменом через временные файлы — разговор с вкладкой
// браузера уехал в deepseek-api, а нам он показывает протокол Ollama.
async function callDeepseek(cfg, text, { n, log }) {
log(`шаг ${n}: ${text.length} символов, спрашиваю DeepSeek…`);
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');
let r;
try {
r = await fetch(`${cfg.deepseekHost}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: cfg.deepseekModel,
stream: false, // иначе придёт NDJSON, а нам нужен объект
messages: [{ role: 'user', content: text }],
}),
// Свой срок больше сервисного: там таймаут считает БЕЗДЕЙСТВИЕ страницы,
// а сюда добавляется ещё и очередь — вкладка одна на всех потребителей.
signal: AbortSignal.timeout((cfg.timeout + 120) * 1000),
});
} catch (e) {
throw new Error(
`deepseek-api не отвечает по адресу ${cfg.deepseekHost}: ${e.message}\n` +
'Запустите start.cmd в папке deepseek-api — браузер он поднимет сам.');
}
const data = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(
`deepseek-api ответил ${r.status}: ${data.error || 'без пояснений'}\n` +
`Что видно во вкладке: curl ${cfg.deepseekHost}/api/probe`);
}
const answer = data.message?.content ?? '';
log(` ответ ${answer.length} символов за ${Math.round((Date.now() - started) / 1000)} с`);
// Мост сам жмёт Retry, когда DeepSeek отвечает «Server is busy», но попытки
// Сервис сам жмёт Retry, когда DeepSeek отвечает «Server is busy», но попытки
// там не бесконечны. Если такой текст всё-таки доехал — это не ответ, и
// разбирать его как ответ нельзя: в блоках-запросах и правках его нет,
// а цикл решил бы, что запросов не поступило, и молча закончил работу.
if (isServerBusy(answer)) throw new Error(
'DeepSeek занят: «Server is busy». Мост исчерпал повторы — попробуйте позже.');
'DeepSeek занят: «Server is busy». Повторы исчерпаны — попробуйте позже.');
return answer;
}
@@ -337,16 +343,27 @@ const FOLLOW_HEAD = [
'',
].join('\n');
async function runAgent(cfg, root, question, files, log) {
const temp = join(tmpdir(), `deepwork-${process.pid}`);
await mkdir(temp, { recursive: true });
// Ход разговора ведём здесь, а не в чате DeepSeek. Сервис памяти не имеет:
// каждый запрос он кладёт в чат целиком, затирая прошлый. Раньше эту роль играл
// флаг --new, и помнил всё сам DeepSeek; теперь стенограмма растёт у нас.
//
// Так даже надёжнее: видно в одном месте, что именно уедет модели, и чужой
// потребитель, вклинившийся между нашими шагами, ничего не портит — мы всё
// приносим заново. Плата — вопрос растёт с каждым шагом на длину прошлого
// ответа; при steps=4 это единицы килобайт против 22 КБ первого сообщения.
function growTranscript(prev, answer, next) {
return [prev, '',
'=== ТВОЙ ПРЕДЫДУЩИЙ ОТВЕТ ===', answer.trim(), '=== END ===', '',
next].join('\n');
}
async function runAgent(cfg, root, question, files, log) {
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 });
answer = await callDeepseek(cfg, text, { n, log });
const requests = parseRequests(answer);
if (!requests.length) { log(`шаг ${n}: запросов нет, готово`); break; }
@@ -360,11 +377,9 @@ async function runAgent(cfg, root, question, files, log) {
log(`шаг ${n}: запрошено ${requests.length}`);
const result = await runRequests(root, requests, log);
text = FOLLOW_HEAD + result;
text = growTranscript(text, answer, FOLLOW_HEAD + result);
}
if (!flags['keep-temp']) await rm(temp, { recursive: true, force: true }).catch(() => {});
else log(`временные файлы: ${temp}`);
return answer;
}
@@ -426,11 +441,14 @@ async function serve(cfg) {
return json(res, 200, { root, files: await listFiles(root) });
if (req.method === 'GET' && url.pathname === '/api/status') {
// 200 от /api/tags значит «браузер поднят и вкладка на месте»; 503 —
// сервис жив, но браузера ещё нет (поднимется на первом же вопросе);
// отказ соединения — сервис не запущен вовсе. Три состояния, и плашка
// в ui.html должна их различать.
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 }); }
const r = await fetch(`${cfg.deepseekHost}/api/tags`, { signal: AbortSignal.timeout(2500) });
return json(res, 200, { service: true, deepseek: r.ok });
} catch { return json(res, 200, { service: false, deepseek: false }); }
}
if (req.method === 'GET' && url.pathname === '/api/progress')
@@ -533,22 +551,27 @@ async function selftest() {
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.chat) cfg.deepseekModel = 'deepseek:' + 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));
// Три состояния, и путать их нельзя: 200 — всё готово; 503 — сервис жив,
// но браузер ещё не поднят (встанет на первом вопросе); отказ соединения —
// сервис не запущен вовсе.
console.log(`сервис: ${cfg.deepseekHost}`);
try {
const r = await fetch(`${cfg.deepseekHost}/api/tags`, { signal: AbortSignal.timeout(3000) });
console.log(r.ok ? 'готов: браузер поднят, вкладка DeepSeek открыта'
: `не готов (HTTP ${r.status}): браузер поднимется на первом вопросе`);
process.exit(r.ok ? 0 : 1);
} catch (e) {
console.error(`не отвечает: ${e.message}`);
console.error('Запустите start.cmd в папке deepseek-api.');
process.exit(1);
}
} else {
const question = positional.join(' ');
if (!question.trim()) { console.log(USAGE); process.exit(question ? 1 : 0); }
+3 -2
View File
@@ -90,8 +90,9 @@ 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';
$('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);