From d8e8821577387536832b158e9e3b2734b692380f Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:49:55 +0300 Subject: [PATCH 1/4] feat(webfetch): improve fetch robustness and add Telegram card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebFetchTool: - charset-aware decoding (content-type → meta → utf-8) via TextDecoder - convert HTML to Markdown fully, then truncate once by maxChars (no more mid-tag cuts or double truncation) - single AbortController for the whole request+body cycle - new optional params: maxChars, raw, timeoutMs, userAgent (UA is only sent when explicitly provided) Telegram bot: - add _renderWebFetch card "AI is browsing a page" with clickable link, HTTP status and body preview in a collapsible quote - handle both success and error with a dedicated card - add fetch.* i18n strings (ru/en) Tests: extend WebFetchTool tests (charset, raw, maxChars, no mid-tag cut) --- botsrc/i18n.js | 16 +++ botsrc/index.js | 100 +++++++++++++ test/tools/WebFetchTool.test.js | 135 +++++++++++++++--- tools/WebFetchTool.js | 242 +++++++++++++++++++++++--------- 4 files changed, 406 insertions(+), 87 deletions(-) diff --git a/botsrc/i18n.js b/botsrc/i18n.js index 7078097..ccadba5 100644 --- a/botsrc/i18n.js +++ b/botsrc/i18n.js @@ -289,6 +289,14 @@ const STRINGS = { "city.tz": "Часовой пояс", "city.place": "Город / страна", + // ===== Просмотр страниц (web_fetch) ===== + "fetch.title": "🌐 ИИ просматривает страницу", + "fetch.url": "URL", + "fetch.status": "Статус", + "fetch.truncated": "…(содержимое обрезано)", + "fetch.empty": "📄 Пустая страница (без текста)", + "fetch.errorTitle": "🌐 Не удалось загрузить страницу", + // ===== Субагенты (run_agent / create_agent / list_agents / read_agent) ===== "agent.runTitle": "🚀 ИИ делегирует задачу субагенту", "agent.resultTitle": "🎯 Субагент вернул результат", @@ -683,6 +691,14 @@ const STRINGS = { "city.tz": "Timezone", "city.place": "City / country", + // ===== Page browsing (web_fetch) ===== + "fetch.title": "🌐 AI is browsing a page", + "fetch.url": "URL", + "fetch.status": "Status", + "fetch.truncated": "…(content truncated)", + "fetch.empty": "📄 Empty page (no text)", + "fetch.errorTitle": "🌐 Failed to load the page", + // ===== Subagents (run_agent / create_agent / list_agents / read_agent) ===== "agent.runTitle": "🚀 AI delegates a task to a subagent", "agent.resultTitle": "🎯 Subagent returned a result", diff --git a/botsrc/index.js b/botsrc/index.js index 3240f62..782ef94 100644 --- a/botsrc/index.js +++ b/botsrc/index.js @@ -4122,6 +4122,95 @@ function _renderCityTime(toolName, ok, args, preview) { return _t("city.title") + "\n\n" + rows.join("\n"); } +/** + * Красивое сообщение для web_fetch: карточка «ИИ просматривает страницу» + * со ссылкой, статусом и превью текста (свёрнутая цитата). + * @returns {string|null} + */ +function _renderWebFetch(toolName, ok, args, preview) { + const name = String(toolName || "").toLowerCase(); + if (name !== "web_fetch" && name !== "webfetch") return null; + + const a = args && typeof args === "object" ? args : {}; + const url = String(a.url || "").trim(); + + // Ошибка — отдельная карточка (доступность, таймаут, протокол). + if (!ok) { + const lines = [_t("fetch.errorTitle")]; + if (url) { + lines.push( + '🔗 ' + + escapeHtml(_techTrunc(url)) + + "", + ); + } + const err = String(preview || "").trim(); + if (err) { + lines.push(""); + lines.push( + "
" + + escapeHtml(_techTruncLong(err, 500)) + + "", + ); + } + return lines.join("\n"); + } + + // Успех: результат вида "Fetched
" + escapeHtml(shown) + ""); + if (truncated || content.length > 1200) { + lines.push("" + escapeHtml(_t("fetch.truncated")) + ""); + } + return lines.join("\n"); +} + /** * Красивое сообщение для run_agent: карточка с именем агента и задачей. * Вызывается из notifyToolResult при старте (args) — а результат агента @@ -4394,6 +4483,17 @@ async function notifyToolResult(toolName, ok, detail) { } } + // Просмотр страниц (web_fetch): собственная карточка со ссылкой, статусом и + // превью текста. Обрабатываем и успех, и ошибку — общий путь для них грубый. + try { + const fetchMsg = _renderWebFetch(toolName, ok, args, preview); + if (fetchMsg) { + return telegramBot.sendMessage(fetchMsg, { parseMode: "HTML" }); + } + } catch (err) { + console.error("[bot] web_fetch render failed:", err.message); + } + // Задачи (todoWrite): собственная карточка со списком задач — ДО ignore-фильтра, // чтобы красивое уведомление приходило всегда (см. решение пользователя). if (ok) { diff --git a/test/tools/WebFetchTool.test.js b/test/tools/WebFetchTool.test.js index 47f1694..297f515 100644 --- a/test/tools/WebFetchTool.test.js +++ b/test/tools/WebFetchTool.test.js @@ -1,38 +1,137 @@ -'use strict'; -const { test } = require('node:test'); -const assert = require('node:assert'); -const { parseFetchArgs, formatFetchOutput } = require('../../tools/WebFetchTool'); +"use strict"; +const { test } = require("node:test"); +const assert = require("node:assert"); +const { + parseFetchArgs, + formatFetchOutput, +} = require("../../tools/WebFetchTool"); -test('parseFetchArgs 正常', () => { - assert.deepStrictEqual(parseFetchArgs('https://example.com'), { url: 'https://example.com' }); +test("parseFetchArgs 正常", () => { + assert.deepStrictEqual(parseFetchArgs("https://example.com"), { + url: "https://example.com", + }); }); -test('parseFetchArgs 空/非字符串抛错', () => { - assert.throws(() => parseFetchArgs(''), /url must be a non-empty string/); +test("parseFetchArgs 空/非字符串抛错", () => { + assert.throws(() => parseFetchArgs(""), /url must be a non-empty string/); assert.throws(() => parseFetchArgs(null), /url must be a non-empty string/); assert.throws(() => parseFetchArgs(123), /url must be a non-empty string/); }); -test('formatFetchOutput html 转 markdown', () => { - const html = '
内容
'; - const out = formatFetchOutput('https://example.com', 200, 'html', html, false); +test("formatFetchOutput html 转 markdown", () => { + const html = "内容
"; + const out = formatFetchOutput( + "https://example.com", + 200, + "html", + html, + false, + ); assert.match(out, /Fetched https:\/\/example\.com \(HTTP 200\)/); assert.match(out, /标题/); assert.match(out, /内容/); }); -test('formatFetchOutput text 原样', () => { - const out = formatFetchOutput('https://example.com', 200, 'text', 'plain text', false); +test("formatFetchOutput text 原样", () => { + const out = formatFetchOutput( + "https://example.com", + 200, + "text", + "plain text", + false, + ); assert.match(out, /plain text/); }); -test('formatFetchOutput 截断 footer', () => { - const long = 'x'.repeat(30000); - const out = formatFetchOutput('https://example.com', 200, 'text', long, false); +test("formatFetchOutput 截断 footer", () => { + const long = "x".repeat(30000); + const out = formatFetchOutput( + "https://example.com", + 200, + "text", + long, + false, + ); assert.match(out, /\(Content truncated/); }); -test('formatFetchOutput 显式 truncated 参数', () => { - const out = formatFetchOutput('https://example.com', 200, 'text', 'abc', true); +test("formatFetchOutput 显式 truncated 参数", () => { + const out = formatFetchOutput( + "https://example.com", + 200, + "text", + "abc", + true, + ); assert.match(out, /\(Content truncated/); }); + +// --- 新功能 --- + +test("formatFetchOutput maxChars 覆盖默认上限", () => { + const body = "a".repeat(5000); + const out = formatFetchOutput( + "https://example.com", + 200, + "text", + body, + false, + { maxChars: 100 }, + ); + assert.match(out, /\(Content truncated/); + // 正文被截到 100 字符 + assert.ok(out.length < 500); +}); + +test("formatFetchOutput maxChars 足够大时不截断", () => { + const body = "short"; + const out = formatFetchOutput( + "https://example.com", + 200, + "text", + body, + false, + { maxChars: 1000 }, + ); + assert.doesNotMatch(out, /\(Content truncated/); +}); + +test("formatFetchOutput raw=true 时 html 不转 markdown", () => { + const html = "" + "x".repeat(30000) + "
"; + const out = formatFetchOutput( + "https://example.com", + 200, + "html", + html, + false, + { maxChars: 200 }, + ); + // 转换后是纯文本,不应含未闭合的+ assert.doesNotMatch(out, /
/);
+});
+
+test("formatFetchOutput HTML 转 markdown 失败时降级为原文", () => {
+ // 传 null 触发 turndown 异常路径(容错)
+ const out = formatFetchOutput(
+ "https://example.com",
+ 200,
+ "html",
+ null,
+ false,
+ );
+ assert.match(out, /Fetched https:\/\/example\.com/);
+});
diff --git a/tools/WebFetchTool.js b/tools/WebFetchTool.js
index 6116953..459260a 100644
--- a/tools/WebFetchTool.js
+++ b/tools/WebFetchTool.js
@@ -1,142 +1,233 @@
-const { Tool, ToolResult } = require('./ToolRegistry');
-const TurndownService = require('turndown');
-const { gfm } = require('@joplin/turndown-plugin-gfm');
+const { Tool, ToolResult } = require("./ToolRegistry");
+const TurndownService = require("turndown");
+const { gfm } = require("@joplin/turndown-plugin-gfm");
-// 内部固定上限,不暴露给模型
-const FETCH_TIMEOUT_MS = 15000;
-const FETCH_MAX_OUTPUT_CHARS = 20000;
+// 默认上限(可被参数覆盖)
+const DEFAULT_TIMEOUT_MS = 15000;
+const DEFAULT_MAX_OUTPUT_CHARS = 20000;
+// 最多读取的原始字节数(防止超大响应)
+const MAX_RAW_BYTES = 512000;
/**
- * HTML → Markdown 转换器(对齐 dsh)。
+ * HTML → Markdown 转换器。
* - atx 标题、fenced 代码块、- 列表
* - GFM 插件支持表格/删除线
* - 移除 script/style/noscript(不保留其文本)
*/
const turndown = new TurndownService({
- headingStyle: 'atx',
- codeBlockStyle: 'fenced',
- bulletListMarker: '-',
+ headingStyle: "atx",
+ codeBlockStyle: "fenced",
+ bulletListMarker: "-",
});
turndown.use(gfm);
-turndown.remove(['script', 'style', 'noscript']);
+turndown.remove(["script", "style", "noscript"]);
/**
- * 对齐 dsh parseFetchArgs:url trim 非空。
+ * 校验 url:trim 非空。
*/
function parseFetchArgs(url) {
- if (typeof url !== 'string' || url.trim().length === 0) {
- throw new Error('url must be a non-empty string');
+ if (typeof url !== "string" || url.trim().length === 0) {
+ throw new Error("url must be a non-empty string");
}
return { url };
}
/**
- * 对齐 dsh renderBody:根据 body kind 处理。
+ * 从 content-type 头解析 charset。返回小写名称或 null。
*/
-function renderBody(kind, content) {
- const sliced = content.slice(0, FETCH_MAX_OUTPUT_CHARS);
- const sourceTruncated = sliced.length !== content.length;
- if (kind === 'html') {
+function charsetFromContentType(contentType) {
+ if (!contentType) return null;
+ const m = /charset\s*=\s*["']?([\w-]+)/i.exec(contentType);
+ return m ? m[1].toLowerCase() : null;
+}
+
+/**
+ * 从 HTML 头部(前若干字节)解析 或 。
+ * buffer 为原始字节。
+ */
+function charsetFromMeta(buffer) {
+ // 只看前 4096 字节,按 latin1 读保证字节透明
+ const head = buffer.slice(0, 4096).toString("latin1");
+ let m = /]+charset\s*=\s*["']?([\w-]+)/i.exec(head);
+ if (m) return m[1].toLowerCase();
+ m =
+ /]+http-equiv\s*=\s*["']?content-type["']?[^>]*content\s*=\s*["'][^"']*charset\s*=\s*([\w-]+)/i.exec(
+ head,
+ );
+ return m ? m[1].toLowerCase() : null;
+}
+
+/**
+ * 解码原始字节为文本。
+ * 优先级:content-type charset → meta charset → utf-8。
+ * 未知/不支持的 charset 回退 utf-8。解码失败再回退 utf-8 且不抛错。
+ */
+function decodeBody(buffer, contentType) {
+ let charset = charsetFromContentType(contentType);
+ if (!charset) charset = charsetFromMeta(buffer);
+ if (!charset) charset = "utf-8";
+
+ try {
+ const decoder = new TextDecoder(charset, { fatal: false });
+ return decoder.decode(buffer);
+ } catch (_) {
+ // 不支持的 charset 名称 → utf-8
+ return new TextDecoder("utf-8", { fatal: false }).decode(buffer);
+ }
+}
+
+/**
+ * 将正文转换为最终文本(可选 HTML→Markdown),并按 maxChars 单次截断。
+ * 返回 { text, truncated }。
+ */
+function renderBody(
+ bodyKind,
+ content,
+ { raw = false, maxChars = DEFAULT_MAX_OUTPUT_CHARS } = {},
+) {
+ let text = typeof content === "string" ? content : "";
+ if (bodyKind === "html" && !raw) {
try {
- return { text: turndown.turndown(sliced), sourceTruncated };
- } catch (e) {
+ text = turndown.turndown(text);
+ } catch (_) {
// 转换失败降级为原始 HTML
- return { text: sliced, sourceTruncated };
+ text = typeof content === "string" ? content : "";
}
}
- // text
- return { text: sliced, sourceTruncated };
+ if (text.length > maxChars) {
+ return { text: text.slice(0, maxChars), truncated: true };
+ }
+ return { text, truncated: false };
}
/**
- * 对齐 dsh formatFetchOutput:
+ * 组装输出:
* Fetched {id}",
+ "sessions.rowCurrent":
+ "{n}. {name} · {date} · {id} · ✅ активная",
+ "sessions.pageInfo": "📄 Страница {page} из {pages}",
+ "sessions.hint": "Нажмите на сессию ниже, чтобы переключиться.",
+ "sessions.today": "сегодня, {time}",
+ "sessions.yesterday": "вчера, {time}",
"sessions.switched": "✅ Переключено на сессию: {id}",
- "sessions.switchFailed": "⚠️ Не удалось переключиться: {err}",
- "sessions.needId": "⚠️ Укажите id сессии: /switch <id>",
+ "sessions.switchFailed":
+ "⚠️ Не удалось переключиться: {err}\n\nПопробуйте /sessions — выбрать из списка.",
+ "sessions.needId":
+ "⚠️ Укажите id сессии: /switch <id>\n\nИли откройте список: /sessions",
+ "sessions.noWindow": "Нет активного окна приложения",
+ "sessions.unknownError": "неизвестная ошибка",
// ===== /log, /show, /files =====
"log.empty": "📭 Коммитов не найдено.",
@@ -603,11 +617,25 @@ const STRINGS = {
// ===== /sessions and /switch =====
"sessions.empty": "📭 No sessions found.",
- "sessions.title": "🗂 Sessions (page {page}/{pages})",
- "sessions.hint": "Switch: /switch <id>",
+ "sessions.header": "🗂 Sessions {scope} · total: {count}",
+ "sessions.scopeProject": "(current project)",
+ "sessions.scopeAll": "(all projects)",
+ "sessions.emptyHint":
+ "Open a chat with a project so it appears here.",
+ "sessions.row": "{n}. {name} · {date} · {id}",
+ "sessions.rowCurrent":
+ "{n}. {name} · {date} · {id} · ✅ active",
+ "sessions.pageInfo": "📄 Page {page} of {pages}",
+ "sessions.hint": "Tap a session below to switch.",
+ "sessions.today": "today, {time}",
+ "sessions.yesterday": "yesterday, {time}",
"sessions.switched": "✅ Switched to session: {id}",
- "sessions.switchFailed": "⚠️ Failed to switch: {err}",
- "sessions.needId": "⚠️ Provide a session id: /switch <id>",
+ "sessions.switchFailed":
+ "⚠️ Failed to switch: {err}\n\nTry /sessions to pick from the list.",
+ "sessions.needId":
+ "⚠️ Provide a session id: /switch <id>\n\nOr open the list: /sessions",
+ "sessions.noWindow": "⚠️ No active app window.",
+ "sessions.unknownError": "unknown error",
// ===== /log, /show, /files =====
"log.empty": "📭 No commits found.",
diff --git a/botsrc/index.js b/botsrc/index.js
index 782ef94..580e060 100644
--- a/botsrc/index.js
+++ b/botsrc/index.js
@@ -1544,6 +1544,23 @@ async function _handleSettingsCallback(data, cbq) {
return;
}
+ // ---- /sessions: выбор сессии кнопкой (st_sw_" + escapeHtml(id) + "",
- (p, pages) => _t("sessions.title", { page: p, pages: pages }),
- _t("sessions.hint"),
+ const total = items.length;
+ const size = pager.pageSize;
+ const pages = Math.max(1, Math.ceil(total / size));
+ const cur = Math.min(Math.max(1, pager.page), pages);
+ pager.page = cur;
+ const slice = items.slice((cur - 1) * size, cur * size);
+
+ // ---- Текст сообщения ----
+ const lines = [];
+ lines.push(
+ _t("sessions.header", {
+ count: total,
+ scope: live.filtered
+ ? _t("sessions.scopeProject")
+ : _t("sessions.scopeAll"),
+ }),
);
+ lines.push("");
+ slice.forEach((it, i) => {
+ const n = (cur - 1) * size + i + 1;
+ const date = _fmtSessionDate(it.lastSeen);
+ const name = escapeHtml(it.name || "—");
+ if (it.isCurrent) {
+ lines.push(
+ _t("sessions.rowCurrent", {
+ n: n,
+ name: name,
+ id: escapeHtml(_shortId(it.id)),
+ date: date ? escapeHtml(date) : "—",
+ }),
+ );
+ } else {
+ lines.push(
+ _t("sessions.row", {
+ n: n,
+ name: name,
+ id: escapeHtml(_shortId(it.id)),
+ date: date ? escapeHtml(date) : "—",
+ }),
+ );
+ }
+ });
+ if (pages > 1)
+ lines.push("", _t("sessions.pageInfo", { page: cur, pages: pages }));
+ lines.push("", _t("sessions.hint"));
+
+ // ---- Клавиатура: по кнопке на сессию + навигация ----
+ const itemRows = [];
+ slice.forEach((it, i) => {
+ const idx = (cur - 1) * size + i;
+ const n = idx + 1;
+ const date = _fmtSessionDate(it.lastSeen);
+ const prefix = it.isCurrent ? "✅" : "▫️";
+ const label =
+ prefix + " " + n + ". " + (it.name || "—") + (date ? " · " + date : "");
+ itemRows.push([{ text: label, callback_data: "st_sw_" + idx }]);
+ });
+
+ const nav = [];
+ if (cur > 1)
+ nav.push({
+ text: _t("page.prev"),
+ callback_data: "st_pg_sessions_" + (cur - 1),
+ });
+ if (cur < pages)
+ nav.push({
+ text: _t("page.next"),
+ callback_data: "st_pg_sessions_" + (cur + 1),
+ });
+ const keyboard = itemRows.slice();
+ if (nav.length) keyboard.push(nav);
+
+ const text = lines.join("\n");
+ const replyMarkup = { inline_keyboard: keyboard };
if (editId) {
- const res = await telegramBot.editMessageText(editId, rendered.text, {
+ const res = await telegramBot.editMessageText(editId, text, {
parseMode: "HTML",
- replyMarkup: rendered.keyboard,
+ replyMarkup,
});
if (res && res.success) return res;
}
- const sent = await telegramBot.sendMessage(rendered.text, {
+ const sent = await telegramBot.sendMessage(text, {
parseMode: "HTML",
- replyMarkup: rendered.keyboard,
+ replyMarkup,
});
if (sent && sent.success && sent.messageId) {
pager.editMessageId = sent.messageId;
@@ -2857,28 +3014,54 @@ async function _cmdSessions(chatId, page, editMessageId) {
return sent;
}
-/** /switch {dir}",
+ "init.empty": "Папок нет.",
+ "init.up": "⬆️ Вверх",
+ "init.pick": "✅ Выбрать эту папку",
+ "init.cancelled": "❌ Инициализация отменена.",
+ "init.expired": "⚠️ Сессия выбора устарела. Запустите /init заново.",
+ "init.starting": "⏳ Инициализирую проект {dir}…",
+ "init.done":
+ "✅ Проект инициализирован: {dir}\nНовый чат открыт, системный промпт отправлен.",
+ "init.failed": "⚠️ Не удалось инициализировать: {err}",
+ "init.badDir": "папка не найдена или не является каталогом",
+ "common.cancel": "❌ Отмена",
"new.failed": "❌ Не удалось открыть новый чат: {err}",
// ===== /diff =====
@@ -451,6 +468,7 @@ const STRINGS = {
"help.cmd.status": "/status — status: window, project, processes",
"help.cmd.stop": "/stop — abort task and kill processes",
"help.cmd.new": "/new — new chat",
+ "help.cmd.init": "/init — initialize project (pick folder)",
"help.cmd.diff": "/diff — show changes (git diff)",
"help.cmd.diagnostics": "/diagnostics — integration diagnostics",
"help.cmd.sessions": "/sessions — project sessions list",
@@ -574,6 +592,22 @@ const STRINGS = {
// ===== /new =====
"new.done": "🆕 New chat opened.",
"new.noWindow": "⚠️ No active window — cannot open a new chat.",
+
+ // ===== /init — project initialization =====
+ "init.title": "🚀 Project initialization",
+ "init.pickDrive": "Pick a drive:",
+ "init.currentDir": "📂 {dir}",
+ "init.empty": "No folders.",
+ "init.up": "⬆️ Up",
+ "init.pick": "✅ Select this folder",
+ "init.cancelled": "❌ Initialization cancelled.",
+ "init.expired": "⚠️ Selection expired. Run /init again.",
+ "init.starting": "⏳ Initializing project {dir}…",
+ "init.done":
+ "✅ Project initialized: {dir}\nNew chat opened, system prompt sent.",
+ "init.failed": "⚠️ Failed to initialize: {err}",
+ "init.badDir": "folder not found or not a directory",
+ "common.cancel": "❌ Cancel",
"new.failed": "❌ Failed to open a new chat: {err}",
// ===== /diff =====
diff --git a/botsrc/index.js b/botsrc/index.js
index 580e060..494c941 100644
--- a/botsrc/index.js
+++ b/botsrc/index.js
@@ -452,6 +452,16 @@ async function _handleCallback(chatId, data, cbq) {
// id сообщения, к которому привязана нажатая кнопка (для editMessageText).
const msgId = cbq && cbq.message && cbq.message.message_id;
+ // Файловый браузер /init (st_init_*) — до общего st_ и до /settings.
+ if (token.startsWith("st_init_")) {
+ try {
+ await _handleInitCallback(token, cbq);
+ } catch (err) {
+ _log("error", "init callback error:", err.message);
+ }
+ return;
+ }
+
// Меню настроек /settings — обрабатываем раньше других, т.к. префикс 'st_'.
// Выбор проекта кнопкой / ручной ввод — обрабатываем до settings.
if (token.startsWith("st_proj_")) {
@@ -2732,6 +2742,257 @@ function _projectKeyboard() {
return _projectView(1).keyboard;
}
+// ===================== /init — инициализация проекта =====================
+
+// Состояние файлового браузера: chatId → { dir, page, entries }.
+// dir = null означает уровень дисков (корень выбора).
+const _initState = new Map();
+const INIT_PAGE_SIZE = 8;
+
+/** Список доступных дисков (Windows — буквы, иначе корень /). */
+function _listDrives() {
+ if (process.platform === "win32") {
+ const drives = [];
+ for (let i = 65; i <= 90; i++) {
+ const root = String.fromCharCode(i) + ":\\";
+ try {
+ if (fs.existsSync(root)) drives.push(root);
+ } catch (_) {}
+ }
+ return drives;
+ }
+ return ["/"];
+}
+
+/** Список подпапок каталога (скрытые пропускаем). */
+function _listSubdirs(dir) {
+ try {
+ return fs
+ .readdirSync(dir, { withFileTypes: true })
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
+ .map((e) => ({ name: e.name, path: path.join(dir, e.name) }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ } catch (_) {
+ return [];
+ }
+}
+
+/** Обрезать подпись кнопки до n символов. */
+function _trunc(s, n) {
+ s = String(s || "");
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
+}
+
+/** Отрисовать текущее состояние файлового браузера /init. */
+async function _renderInit(chatId, editMessageId) {
+ const st = _initState.get(chatId) || { dir: null, page: 1, entries: [] };
+ _initState.set(chatId, st);
+
+ const atRoot = !st.dir;
+ const entries = atRoot
+ ? _listDrives().map((d) => ({ name: "💽 " + d, path: d }))
+ : _listSubdirs(st.dir).map((e) => ({ name: "📁 " + e.name, path: e.path }));
+
+ const size = INIT_PAGE_SIZE;
+ const pages = Math.max(1, Math.ceil(entries.length / size));
+ const cur = Math.min(Math.max(1, st.page || 1), pages);
+ st.page = cur;
+ st.entries = entries;
+ const slice = entries.slice((cur - 1) * size, cur * size);
+
+ const lines = [_t("init.title")];
+ if (atRoot) {
+ lines.push(_t("init.pickDrive"));
+ } else {
+ lines.push(_t("init.currentDir", { dir: escapeHtml(st.dir) }));
+ }
+ lines.push("");
+ if (entries.length === 0) lines.push(_t("init.empty"));
+
+ const rows = [];
+ slice.forEach((e, i) => {
+ const idx = (cur - 1) * size + i;
+ rows.push([
+ { text: _trunc(e.name, 60), callback_data: "st_init_open_" + idx },
+ ]);
+ });
+
+ const nav = [];
+ if (!atRoot) nav.push({ text: _t("init.up"), callback_data: "st_init_up" });
+ if (pages > 1) {
+ if (cur > 1)
+ nav.push({ text: "◀️", callback_data: "st_init_pg_" + (cur - 1) });
+ nav.push({ text: cur + "/" + pages, callback_data: "st_init_noop" });
+ if (cur < pages)
+ nav.push({ text: "▶️", callback_data: "st_init_pg_" + (cur + 1) });
+ }
+ if (nav.length) rows.push(nav);
+
+ if (!atRoot)
+ rows.push([{ text: _t("init.pick"), callback_data: "st_init_pick" }]);
+ rows.push([{ text: _t("common.cancel"), callback_data: "st_init_cancel" }]);
+
+ const text = lines.join("\n");
+ const replyMarkup = { inline_keyboard: rows };
+
+ if (editMessageId) {
+ const res = await telegramBot.editMessageText(editMessageId, text, {
+ parseMode: "HTML",
+ replyMarkup,
+ });
+ if (res && res.success) return res;
+ }
+ return telegramBot.sendMessage(text, { parseMode: "HTML", replyMarkup });
+}
+
+/** /init — открыть файловый браузер для выбора папки нового проекта. */
+async function _cmdInit(chatId) {
+ _initState.set(chatId, { dir: null, page: 1, entries: [] });
+ return _renderInit(chatId, null);
+}
+
+/**
+ * Инициализировать проект: открыть новый чат, привязать папку и отправить
+ * системный промпт проекта. Всё делаем из main-процесса (без инжекта).
+ * @returns {{success: boolean, dir?: string, error?: string}}
+ */
+async function _doInitProject(chatId, dir) {
+ const win = windowState.getParentWindow
+ ? windowState.getParentWindow()
+ : windowState.getMainWindow();
+ if (!win || win.isDestroyed())
+ return { success: false, error: _t("new.noWindow") };
+
+ // Проверяем, что каталог существует.
+ try {
+ const st = fs.statSync(dir);
+ if (!st.isDirectory()) return { success: false, error: _t("init.badDir") };
+ } catch (_) {
+ return { success: false, error: _t("init.badDir") };
+ }
+
+ // Запоминаем проект как pending: привяжется к sessionId нового чата.
+ const ctx = _activeContext();
+ const store = ctx && ctx.sessionStore;
+ if (store) {
+ store.state.pendingProjectDir = dir;
+ store.state.lastProjectDir = dir;
+ }
+
+ // Навигация на home (новый чат).
+ let homeUrl = null;
+ try {
+ const { getProviderByUrl } = require("../src/providers");
+ const provider = getProviderByUrl(win.webContents.getURL());
+ if (provider && provider.homeUrl) homeUrl = provider.homeUrl;
+ } catch (_) {}
+ if (!homeUrl) homeUrl = "https://chat.deepseek.com/";
+
+ try {
+ await win.webContents.loadURL(homeUrl);
+ } catch (err) {
+ return { success: false, error: err.message };
+ }
+
+ // Собираем и отправляем промпт после готовности страницы.
+ // Ждём did-finish-load, иначе preload ещё не успел подписаться на канал
+ // и initial-prompt потеряется. Дублируем таймаутом — как в subagent.js.
+ const providerId = (ctx && ctx.providerId) || "";
+ let sent = false;
+ const doSend = async () => {
+ if (sent) return;
+ if (!win || win.isDestroyed()) return;
+ try {
+ const { buildInitPrompt } = require("../src/main/project-context");
+ const combined = await buildInitPrompt(dir, providerId);
+ if (combined && !win.isDestroyed()) {
+ win.webContents.send("initial-prompt", combined);
+ sent = true;
+ }
+ } catch (_) {}
+ };
+ try {
+ win.webContents.once("did-finish-load", () => setTimeout(doSend, 1200));
+ } catch (_) {}
+ setTimeout(doSend, 4000);
+
+ return { success: true, dir };
+}
+
+/** Обработка callback-кнопок /init (st_init_*). */
+async function _handleInitCallback(data, cbq) {
+ const chatId = String(cbq.message && cbq.message.chat && cbq.message.chat.id);
+ const msgId = cbq.message && cbq.message.message_id;
+ await telegramBot.answerCallbackQuery(cbq.id);
+
+ const st = _initState.get(chatId);
+ if (!st) {
+ await telegramBot.sendMessage(_t("init.expired"));
+ return;
+ }
+
+ if (data === "st_init_noop") return;
+
+ if (data === "st_init_cancel") {
+ _initState.delete(chatId);
+ if (msgId) {
+ await telegramBot.editMessageText(msgId, _t("init.cancelled"), {
+ parseMode: "HTML",
+ replyMarkup: { inline_keyboard: [] },
+ });
+ }
+ return;
+ }
+
+ if (data === "st_init_up") {
+ if (st.dir) {
+ const parent = path.dirname(st.dir);
+ st.dir = parent && parent !== st.dir ? parent : null;
+ st.page = 1;
+ }
+ return _renderInit(chatId, msgId);
+ }
+
+ if (data.startsWith("st_init_pg_")) {
+ st.page = parseInt(data.slice("st_init_pg_".length), 10) || 1;
+ return _renderInit(chatId, msgId);
+ }
+
+ if (data.startsWith("st_init_open_")) {
+ const idx = parseInt(data.slice("st_init_open_".length), 10);
+ const entry = st.entries && st.entries[idx];
+ if (!entry) return;
+ st.dir = entry.path;
+ st.page = 1;
+ return _renderInit(chatId, msgId);
+ }
+
+ if (data === "st_init_pick") {
+ const dir = st.dir;
+ if (!dir) return;
+ _initState.delete(chatId);
+ if (msgId) {
+ await telegramBot.editMessageText(
+ msgId,
+ _t("init.starting", { dir: escapeHtml(dir) }),
+ { parseMode: "HTML", replyMarkup: { inline_keyboard: [] } },
+ );
+ }
+ const r = await _doInitProject(chatId, dir);
+ if (!r.success) {
+ await telegramBot.sendMessage(
+ _t("init.failed", { err: escapeHtml(r.error || "") }),
+ { parseMode: "HTML" },
+ );
+ } else {
+ await telegramBot.sendMessage(_t("init.done", { dir: escapeHtml(dir) }), {
+ parseMode: "HTML",
+ });
+ }
+ return;
+ }
+}
+
/**
* Установить проект в активном окне (через electronAPI того же окна).
* Пишем selectedProjectDir в session-store активного профиля и уведомляем renderer.
@@ -3222,6 +3483,7 @@ async function _cmdHelp() {
_t("help.cmd.status"),
_t("help.cmd.stop"),
_t("help.cmd.new"),
+ _t("help.cmd.init"),
_t("help.cmd.diff"),
_t("help.cmd.diagnostics"),
_t("help.cmd.sessions"),
@@ -3664,6 +3926,10 @@ async function _handleIncoming(chatId, text, msg) {
await _cmdNew();
return;
}
+ if (cmd === "/init") {
+ await _cmdInit(chatId);
+ return;
+ }
if (cmd === "/projects" || cmd === "/project" || cmd === "/proj") {
const view = _projectView(_projectPage.get(chatId) || 1);
await telegramBot.sendMessage(view.text, {
From ebcf7545869165b2dfd4588dda90c6de1dd5a952 Mon Sep 17 00:00:00 2001
From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com>
Date: Sun, 11 Oct 2026 13:16:42 +0300
Subject: [PATCH 4/4] fix(bot): locate DeepSeek toggles by class index and
register /init command
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
DeepSeek toggles were matched by button text (deepthink/search), which fails on localized pages (e.g. ru: «Глубокое мышление»/«Умный поиск»). Now select by .ds-toggle-button order: [0]=DeepThink, [1]=Search. Also register the /init command for ru and en.
---
botsrc/index.js | 30 +++++++++---------------------
1 file changed, 9 insertions(+), 21 deletions(-)
diff --git a/botsrc/index.js b/botsrc/index.js
index 494c941..d355956 100644
--- a/botsrc/index.js
+++ b/botsrc/index.js
@@ -2431,18 +2431,11 @@ async function _readTogglesState() {
if (!win || win.isDestroyed()) return null;
const script = `(function(){
- const find = (needle) => {
- const all = document.querySelectorAll('.ds-toggle-button');
- for (const el of all) {
- const t = (el.textContent || '').trim().toLowerCase();
- if (t.indexOf(needle) >= 0) return el;
- }
- return null;
- };
- const think = find('deepthink');
- const search = find('search');
+ const all = document.querySelectorAll('.ds-toggle-button');
+ const think = all[0] || null;
+ const search = all[1] || null;
const state = (el) => el ? el.getAttribute('aria-pressed') === 'true' : null;
- return { think: state(think), search: state(search) };
+ return { think: state(think), search: state(search), count: all.length };
})()`;
try {
@@ -2464,17 +2457,10 @@ async function _toggleDeepSeek(name) {
: windowState.getMainWindow();
if (!win || win.isDestroyed()) return { ok: false, error: "noWindow" };
- const needle = name === "think" ? "deepthink" : "search";
+ const idx = name === "think" ? 0 : 1;
const script = `(function(){
- const find = (n) => {
- const all = document.querySelectorAll('.ds-toggle-button');
- for (const el of all) {
- const t = (el.textContent || '').trim().toLowerCase();
- if (t.indexOf(n) >= 0) return el;
- }
- return null;
- };
- const el = find('${needle}');
+ const all = document.querySelectorAll('.ds-toggle-button');
+ const el = all[${idx}];
if (!el) return { ok: false, error: 'notFound' };
const before = el.getAttribute('aria-pressed') === 'true';
el.click();
@@ -5051,6 +5037,7 @@ async function _registerCommands() {
ru: {
start: "начать / справка",
help: "справка",
+ init: "инициализация проекта",
settings: "настройки",
status: "статус",
stop: "остановить задачу",
@@ -5072,6 +5059,7 @@ async function _registerCommands() {
en: {
start: "start / help",
help: "help",
+ init: "initialize project",
settings: "settings",
status: "status",
stop: "stop task",