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 (HTTP )" + тело + footer. + const body = _stripResultWrapper(String(preview || "")).trim(); + const headerMatch = body.match(/^Fetched\s+(\S+)\s+\(HTTP\s+(\d+)\)/i); + const shownUrl = (headerMatch && headerMatch[1]) || url || ""; + const status = headerMatch ? headerMatch[2] : ""; + + // Отрезаем служебный заголовок и footer, оставляя полезный текст. + let content = body; + if (headerMatch) content = body.slice(headerMatch[0].length); + content = content + .replace(/\n*\n?\(Content truncated\.[^)]*\)\s*$/i, "") + .replace(/^\s*\n+/, "") + .trim(); + + const truncated = /\(Content truncated\./i.test(body); + + const lines = [_t("fetch.title")]; + lines.push(""); + if (shownUrl) { + lines.push( + '🔗 ' + + escapeHtml(_techTrunc(shownUrl)) + + "", + ); + } + if (status) { + lines.push( + "📡 " + + escapeHtml(_t("fetch.status")) + + ": " + + escapeHtml(status) + + "", + ); + } + + if (!content) { + lines.push(""); + lines.push(_t("fetch.empty")); + return lines.join("\n"); + } + + // Превью текста (первые ~1200 символов), свёрнутая цитата. + const shown = content.length > 1200 ? content.slice(0, 1200) : content; + lines.push(""); + lines.push("
" + 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 = "

标题

"; + const out = formatFetchOutput( + "https://example.com", + 200, + "html", + html, + false, + { raw: true }, + ); + assert.match(out, /

标题<\/h1>/); +}); + +test("formatFetchOutput 不在 HTML 标签中间截断(先转换后截断)", () => { + // 构造一个长 HTML,若在原始 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 (HTTP ) <正文> - * 截断时加 footer。 + * 截断时加 footer。maxChars 为正文(不含 header/footer)上限。 */ -function formatFetchOutput(url, statusCode, bodyKind, bodyContent, truncated) { - const rendered = renderBody(bodyKind, bodyContent); - const effectiveTruncated = truncated || rendered.sourceTruncated || rendered.text.length > FETCH_MAX_OUTPUT_CHARS; - const header = 'Fetched ' + url + ' (HTTP ' + statusCode + ')\n\n'; - const footer = effectiveTruncated ? '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)' : ''; - let full = header + rendered.text + footer; - if (full.length > FETCH_MAX_OUTPUT_CHARS) { - full = full.slice(0, FETCH_MAX_OUTPUT_CHARS) + footer; - } - return full; +function formatFetchOutput( + url, + statusCode, + bodyKind, + bodyContent, + truncated, + options = {}, +) { + const maxChars = options.maxChars || DEFAULT_MAX_OUTPUT_CHARS; + const rendered = renderBody(bodyKind, bodyContent, { ...options, maxChars }); + const effectiveTruncated = Boolean(truncated) || rendered.truncated; + const header = "Fetched " + url + " (HTTP " + statusCode + ")\n\n"; + const footer = effectiveTruncated + ? "\n\n(Content truncated. Fetch a more specific URL or section for the full text.)" + : ""; + return header + rendered.text + footer; } /** - * web_fetch 工具 - 对齐 dsh。 - * 只接受 url 参数,返回转 Markdown 后的纯文本。 + * web_fetch 工具。 + * 只要求 url,其余参数可选。返回转 Markdown 后的纯文本。 */ class WebFetchTool extends Tool { constructor() { super( - 'web_fetch', - '获取指定 HTTP(S) URL 的内容并解码为文本。HTML 会转换为 Markdown(turndown + GFM)。返回纯文本:Fetched (HTTP ) + 正文。内容超过上限(约 20000 字符)会截断并附 footer。', + "web_fetch", + "获取指定 HTTP(S) URL 的内容并解码为文本。HTML 会转换为 Markdown(turndown + GFM)。返回纯文本:Fetched (HTTP ) + 正文。可选参数:maxChars(正文上限,默认 20000)、raw(true 则不做 HTML→Markdown)、timeoutMs(默认 15000)、userAgent(自定义 UA,不传则不发送该头)。内容超限会截断并附 footer。", { - type: 'object', + type: "object", properties: { url: { - type: 'string', - description: '要获取的 HTTP(S) URL' - } + type: "string", + description: "要获取的 HTTP(S) URL", + }, + maxChars: { + type: "number", + description: "正文最大字符数(默认 20000)", + }, + raw: { + type: "boolean", + description: "为 true 时不做 HTML→Markdown 转换,直接返回原文", + }, + timeoutMs: { + type: "number", + description: "请求超时毫秒数(默认 15000)", + }, + userAgent: { + type: "string", + description: "自定义 User-Agent 请求头;不传则不发送该头", + }, }, - required: ['url'], - additionalProperties: false + required: ["url"], + additionalProperties: false, }, - 'webFetch(url)' + "webFetch(url, maxChars?, raw?, timeoutMs?, userAgent?)", ); } getPromptSection() { return { - name: 'tool:web_fetch', + name: "tool:web_fetch", order: 111, - text: '使用 webFetch 工具获取指定 HTTP(S) URL 的内容。返回解码为文本的页面内容(HTML 转 Markdown)。内容超过约 20000 字符会截断并附 footer。使用其内容时,请以 markdown 链接形式引用 URL。' + text: "使用 webFetch 工具获取指定 HTTP(S) URL 的内容。返回解码为文本的页面内容(HTML 转 Markdown)。默认正文上限约 20000 字符,超出会截断并附 footer;可用 maxChars / raw / timeoutMs / userAgent 调整。使用其内容时,请以 markdown 链接形式引用 URL。", }; } async execute(params) { - const { url } = params; + const { url, maxChars, raw, timeoutMs, userAgent } = params; try { const input = parseFetchArgs(url); // 安全限制:只允许 http/https const parsedUrl = new URL(input.url); - if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - return ToolResult.error('仅支持 http/https 协议'); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + return ToolResult.error("仅支持 http/https 协议"); + } + + const effectiveTimeout = + typeof timeoutMs === "number" && timeoutMs > 0 + ? timeoutMs + : DEFAULT_TIMEOUT_MS; + const effectiveMaxChars = + typeof maxChars === "number" && maxChars > 0 + ? Math.floor(maxChars) + : DEFAULT_MAX_OUTPUT_CHARS; + + const headers = {}; + if (typeof userAgent === "string" && userAgent.trim().length > 0) { + headers["User-Agent"] = userAgent; } const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout); try { const response = await fetch(input.url, { - method: 'GET', + method: "GET", signal: controller.signal, + headers, }); - // 流式读取,限制大小 + // 流式读取,限制大小(原始字节) const reader = response.body ? response.body.getReader() : null; let receivedBytes = 0; const chunks = []; let truncated = false; - const MAX_BYTES = 512000; if (reader) { while (true) { const { done, value } = await reader.read(); if (done) break; receivedBytes += value.byteLength; - if (receivedBytes > MAX_BYTES) { - const remaining = MAX_BYTES - (receivedBytes - value.byteLength); + if (receivedBytes > MAX_RAW_BYTES) { + const remaining = + MAX_RAW_BYTES - (receivedBytes - value.byteLength); if (remaining > 0) chunks.push(value.slice(0, remaining)); truncated = true; - try { await reader.cancel(); } catch (_) {} + try { + await reader.cancel(); + } catch (_) {} break; } chunks.push(value); @@ -145,25 +236,38 @@ class WebFetchTool extends Tool { clearTimeout(timeoutId); - const buffer = Buffer.concat(chunks.map(c => Buffer.from(c))); - const rawText = buffer.toString('utf8'); + const buffer = chunks.length ? Buffer.concat(chunks) : Buffer.alloc(0); // 判断 body kind:content-type 含 html 则为 html,否则 text - const contentType = response.headers.get('content-type') || ''; - const bodyKind = contentType.includes('text/html') || contentType.includes('application/xhtml') ? 'html' : 'text'; + const contentType = response.headers.get("content-type") || ""; + const bodyKind = + contentType.includes("text/html") || + contentType.includes("application/xhtml") + ? "html" + : "text"; + + // 解码(charset 感知) + const rawText = decodeBody(buffer, contentType); - console.log('[WebFetchTool] 抓取完成:', input.url, 'HTTP', response.status, 'kind=' + bodyKind); + const output = formatFetchOutput( + response.url || input.url, + response.status, + bodyKind, + rawText, + truncated, + { raw: raw === true, maxChars: effectiveMaxChars }, + ); - return ToolResult.success(formatFetchOutput(response.url || input.url, response.status, bodyKind, rawText, truncated)); + return ToolResult.success(output); } catch (err) { clearTimeout(timeoutId); - if (err.name === 'AbortError') { - return ToolResult.error('请求超时 (超过 ' + FETCH_TIMEOUT_MS + 'ms)'); + if (err.name === "AbortError") { + return ToolResult.error("请求超时 (超过 " + effectiveTimeout + "ms)"); } - return ToolResult.error('请求失败: ' + err.message); + return ToolResult.error("请求失败: " + err.message); } } catch (err) { - return ToolResult.error('web_fetch 失败: ' + err.message); + return ToolResult.error("web_fetch 失败: " + err.message); } } } From 64527a3142da3763bf3d8bdd5498b65baa4bd41d Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:03:11 +0300 Subject: [PATCH 2/4] Refactor sessions UI and switch flow Revamp /sessions and /switch: add richer i18n (RU/EN) keys; implement live session reading from main (session-store + stats-store), formatted dates, short IDs and project labels; render sessions with per-session inline buttons and pager (pageSize=6); add callback handler (st_sw_) and direct main-process navigation (_navigateToSession) instead of relying on injected window.electronAPI; improve error messages and switch feedback. Also remove a stale test temp session file. --- botsrc/i18n.js | 44 ++- botsrc/index.js | 269 +++++++++++++++--- .../session-dir-map-p1.json | 3 - 3 files changed, 262 insertions(+), 54 deletions(-) delete mode 100644 test/tmp/session-store-test/session-dir-map-p1.json diff --git a/botsrc/i18n.js b/botsrc/i18n.js index ccadba5..c3c6af0 100644 --- a/botsrc/i18n.js +++ b/botsrc/i18n.js @@ -201,11 +201,25 @@ const STRINGS = { // ===== /sessions и /switch ===== "sessions.empty": "📭 Сессий не найдено.", - "sessions.title": "🗂 Сессии (стр. {page}/{pages})", - "sessions.hint": "Переключиться: /switch <id>", + "sessions.header": "🗂 Сессии {scope} · всего: {count}", + "sessions.scopeProject": "(текущий проект)", + "sessions.scopeAll": "(все проекты)", + "sessions.emptyHint": + "Запустите чат с проектом, чтобы он появился здесь.", + "sessions.row": "{n}. {name} · {date} · {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_) ---- + if (data.startsWith("st_sw_")) { + const idx = parseInt(data.slice("st_sw_".length), 10); + await telegramBot.answerCallbackQuery(cbq.id); + const pager = _pagers.get(chatId + ":sessions"); + const item = pager && pager.items && pager.items[idx]; + if (!item) { + await telegramBot.sendMessage( + _t("sessions.switchFailed", { err: "session-not-found" }), + { parseMode: "HTML" }, + ); + return; + } + await _cmdSwitch(chatId, item.id); + return; + } + // ---- Пагинация списков (/sessions, /log) ---- if (data.startsWith("st_pg_")) { const rest = data.slice("st_pg_".length); @@ -2784,7 +2801,92 @@ function _renderPager(type, pager, renderItem, titleFn, hint) { return { text: lines.join("\n"), keyboard: { inline_keyboard: keyboard } }; } -/** /sessions — список сессий проекта. */ +/** Форматировать timestamp в человекочитаемую дату («сегодня, 14:30»). */ +function _fmtSessionDate(ts) { + if (!ts) return ""; + try { + const d = new Date(ts); + const pad = (n) => String(n).padStart(2, "0"); + const hhmm = pad(d.getHours()) + ":" + pad(d.getMinutes()); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const day = new Date(d); + day.setHours(0, 0, 0, 0); + const diffDays = Math.round((today - day) / 86400000); + if (diffDays === 0) return _t("sessions.today", { time: hhmm }); + if (diffDays === 1) return _t("sessions.yesterday", { time: hhmm }); + return pad(d.getDate()) + "." + pad(d.getMonth() + 1) + ", " + hhmm; + } catch (_) { + return ""; + } +} + +/** + * Прочитать живой список сессий НАПРЯМУЮ из main-процесса. + * Возвращает { items: [{ id, projectDir, name, lastSeen, isCurrent }], filtered } + * или { error }. + * + * Если в окне выбран проект — показываем только его сессии. Если проект не выбран + * (частый случай: чат открыт, но projectDir не привязан), показываем ВСЕ сессии + * из store — иначе /sessions бесполезно уходил в меню выбора проекта. + * + * Важно: НЕ используем window.electronAPI через инжект — в main world он может + * быть недоступен (contextBridge изолирует изолированный мир), из-за чего список + * не читался и /switch молчал. Читаем sessionStore и stats-store напрямую. + */ +function _readLiveSessions() { + const ctx = _activeContext(); + const store = ctx && ctx.sessionStore; + if (!store) return { items: [], filtered: false }; + let all = {}; + try { + all = store.readSessionStore() || {}; + } catch (err) { + return { error: err.message }; + } + const currentId = store.state.currentSessionId || null; + const projectDir = + _projectDir() || (store.state && store.state.lastProjectDir) || null; + let statsSessions = {}; + try { + statsSessions = + require("../src/main/stats-store").readStats().sessions || {}; + } catch (_) { + statsSessions = {}; + } + // Фильтруем по проекту, только если он известен. Иначе — все сессии. + const ids = Object.keys(all); + const filtered = Boolean(projectDir); + const chosen = filtered ? ids.filter((id) => all[id] === projectDir) : ids; + const items = chosen + .map((id) => { + const s = statsSessions[id] || null; + const dir = all[id] || ""; + return { + id, + projectDir: dir, + name: _projectLabel(dir), + lastSeen: s && s.lastSeen ? s.lastSeen : 0, + isCurrent: id === currentId, + }; + }) + .sort((a, b) => b.lastSeen - a.lastSeen); + return { items, filtered }; +} + +/** Короткий id для кнопки: первые 8 символов. */ +function _shortId(id) { + return String(id || "").slice(0, 8); +} + +/** Имя папки проекта для показа в списке (basename пути). */ +function _projectLabel(dir) { + const s = String(dir || "").replace(/[\\/]+$/, ""); + const parts = s.split(/[\\/]/); + return parts[parts.length - 1] || s || "—"; +} + +/** /sessions — живой список сессий проекта с кнопками выбора. */ async function _cmdSessions(chatId, page, editMessageId) { const key = chatId + ":sessions"; const prev = _pagers.get(key); @@ -2806,49 +2908,104 @@ async function _cmdSessions(chatId, page, editMessageId) { return sent; }; - const projectDir = _projectDir(); - // Проект не выбран: подсказываем, как выбрать его кнопками. - if (!projectDir) - return reply( - escapeHtml(_t("diff.noProject")) + "\n" + _t("project.pickHint"), - { - replyMarkup: _projectKeyboard(), - }, - ); - let sessions = []; - try { - const ctx = _activeContext(); - const store = ctx && ctx.sessionStore; - const all = store.readSessionStore(); - sessions = Object.keys(all).filter((id) => all[id] === projectDir); - } catch (err) { - return reply("⚠ " + escapeHtml(err.message)); - } - if (sessions.length === 0) return reply(_t("sessions.empty")); + // Всегда читаем список заново — никакого кэша в _pagers. + // Проект может быть не выбран: тогда показываем все сессии, а не меню выбора. + const live = _readLiveSessions(); + if (live.error) return reply("⚠ " + escapeHtml(live.error)); + const items = live.items || []; + if (items.length === 0) + return reply(_t("sessions.empty") + "\n\n" + _t("sessions.emptyHint")); - const pager = { items: sessions, page: page || 1, pageSize: 10 }; + const pager = { items, page: page || 1, pageSize: 6 }; pager.editMessageId = editId; _pagers.set(key, pager); - const rendered = _renderPager( - "sessions", - pager, - (id) => "• " + 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 — переключиться на сессию. */ +/** + * Переключить окно на сессию НАПРЯМУЮ из main-процесса. + * Не используем window.electronAPI через инжект: в main world он может быть + * недоступен, из-за чего /switch молчал. Навигацию делает main через loadURL. + * Возвращает { success, error? }. + */ +async function _navigateToSession(id) { + const win = windowState.getParentWindow + ? windowState.getParentWindow() + : windowState.getMainWindow(); + if (!win || win.isDestroyed()) + return { success: false, error: _t("sessions.noWindow") }; + let url = null; + try { + const { getProviderByUrl } = require("../src/providers"); + const provider = getProviderByUrl(win.webContents.getURL()); + if (provider && typeof provider.getSessionUrl === "function") { + url = provider.getSessionUrl(id); + } else if (provider && provider.sessionUrlBase) { + url = provider.sessionUrlBase + id; + } + } catch (_) {} + if (!url) url = "https://chat.deepseek.com/a/chat/s/" + id; + try { + await win.webContents.loadURL(url); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +/** /switch — переключиться на сессию (вызывается и из кнопок /sessions). */ async function _cmdSwitch(chatId, id) { if (!id) return telegramBot.sendMessage(_t("sessions.needId"), { parseMode: "HTML", }); - const res = await _evalInWindow( - "(async () => { try { return await window.electronAPI.navigateSession(" + - JSON.stringify(id) + - "); } catch (e) { return { success:false, error:e.message }; } })()", - ); - const inner = (res && res.result) || {}; - if (!res.success || inner.success === false) + const res = await _navigateToSession(id); + if (!res.success) { return telegramBot.sendMessage( _t("sessions.switchFailed", { - err: escapeHtml( - (inner && inner.error) || (res && res.error) || "unknown", - ), + err: escapeHtml(res.error || _t("sessions.unknownError")), }), + { parseMode: "HTML" }, ); + } return telegramBot.sendMessage( - _t("sessions.switched", { id: escapeHtml(id) }), + _t("sessions.switched", { id: escapeHtml(_shortId(id)) }), { parseMode: "HTML" }, ); } diff --git a/test/tmp/session-store-test/session-dir-map-p1.json b/test/tmp/session-store-test/session-dir-map-p1.json deleted file mode 100644 index 7e49d73..0000000 --- a/test/tmp/session-store-test/session-dir-map-p1.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "a": "dir1" -} \ No newline at end of file From d35baa84cbe549b2b79101271ae19a3487d40afd Mon Sep 17 00:00:00 2001 From: MerfiDEV <184786614+merfiDEV@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:13:16 +0300 Subject: [PATCH 3/4] feat(telegram): redesign /sessions, fix /switch, add /init Read sessions directly from the main process (sessionStore + stats-store) instead of injecting window.electronAPI into the main world, which could be unavailable and caused a stale session list and silent /switch. Always re-read the list (drop the _pagers cache). Show all sessions when no project is selected, filter by project otherwise; display the project folder name (basename) with a small short-id footnote, human-readable dates (e.g. 'today, 14:30') and sorting by lastSeen; each session is an inline button (st_sw_) with the active one marked. Fix /switch by navigating directly from the main process via win.webContents.loadURL with clearer errors and a /sessions hint. Add /init: an inline file browser (drives -> folders, Up navigation, pagination, 'Select this folder' / 'Cancel') that opens a new chat, binds the folder as pendingProjectDir, builds the project system prompt and sends it via initial-prompt after did-finish-load. Update /help and i18n (init.*, common.cancel, help.cmd.init in ru and en). --- botsrc/i18n.js | 34 +++++++ botsrc/index.js | 266 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) diff --git a/botsrc/i18n.js b/botsrc/i18n.js index c3c6af0..31b6b69 100644 --- a/botsrc/i18n.js +++ b/botsrc/i18n.js @@ -36,6 +36,7 @@ const STRINGS = { "help.cmd.status": "/status — статус: окно, проект, процессы", "help.cmd.stop": "/stop — прервать задачу и убить процессы", "help.cmd.new": "/new — новый чат", + "help.cmd.init": "/init — инициализировать проект (выбор папки)", "help.cmd.diff": "/diff — показать изменения (git diff)", "help.cmd.diagnostics": "/diagnostics — диагностика интеграции", "help.cmd.sessions": "/sessions — список сессий проекта", @@ -158,6 +159,22 @@ const STRINGS = { // ===== /new ===== "new.done": "🆕 Новый чат открыт.", "new.noWindow": "⚠️ Нет активного окна — не удалось открыть новый чат.", + + // ===== /init — инициализация проекта ===== + "init.title": "🚀 Инициализация проекта", + "init.pickDrive": "Выберите диск:", + "init.currentDir": "📂 {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",