diff --git a/README.md b/README.md index f475595..d5048c6 100644 --- a/README.md +++ b/README.md @@ -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 с. ## Что проверено diff --git a/config.example.json b/config.example.json index b31d655..de07217 100644 --- a/config.example.json +++ b/config.example.json @@ -1,6 +1,6 @@ { - "bridge": "C:\\#Projects\\ollama_orchestro\\ask.mjs", - "chat": "think", + "deepseekHost": "http://localhost:11500", + "deepseekModel": "deepseek:think", "steps": 4, "timeout": 240 } diff --git a/deepwork.mjs b/deepwork.mjs index 3e7e333..79a5b54 100644 --- a/deepwork.mjs +++ b/deepwork.mjs @@ -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); } diff --git a/ui.html b/ui.html index 86ebf0b..a324cfd 100644 --- a/ui.html +++ b/ui.html @@ -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);