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:
co-authored by
Claude Opus 5
parent
3586e32a89
commit
44f07c8af9
+87
-64
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user