{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 =====
@@ -201,11 +218,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": "📭 Коммитов не найдено.",
@@ -289,6 +320,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": "🎯 Субагент вернул результат",
@@ -429,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",
@@ -552,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 =====
@@ -595,11 +651,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.",
@@ -683,6 +753,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..d355956 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_")) {
@@ -1544,6 +1554,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 +3261,54 @@ async function _cmdSessions(chatId, page, editMessageId) {
return sent;
}
-/** /switch " + + 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 +4918,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) { @@ -4502,6 +5037,7 @@ async function _registerCommands() { ru: { start: "начать / справка", help: "справка", + init: "инициализация проекта", settings: "настройки", status: "статус", stop: "остановить задачу", @@ -4523,6 +5059,7 @@ async function _registerCommands() { en: { start: "start / help", help: "help", + init: "initialize project", settings: "settings", status: "status", stop: "stop task", 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 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