zapmyco 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.mjs +1127 -48
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +50 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{loader-BVZ993Oq.mjs → loader-BNgN6Pz5.mjs} +44 -2
- package/dist/loader-BNgN6Pz5.mjs.map +1 -0
- package/package.json +4 -2
- package/dist/loader-BVZ993Oq.mjs.map +0 -1
package/dist/cli/index.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { D as __VERSION__, E as APP_NAME, T as eventBus, f as WebError, g as createLlmBasedAgent, m as ZapmycoErrorCode, o as logger, t as loadConfig } from "../loader-BNgN6Pz5.mjs";
|
|
3
3
|
import chalk, { Chalk } from "chalk";
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import { getModel } from "@mariozechner/pi-ai";
|
|
6
|
-
import { Container, Editor, Key, ProcessTerminal, TUI,
|
|
6
|
+
import { Container, Editor, Key, ProcessTerminal, TUI, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
7
|
+
import TurndownService from "turndown";
|
|
8
|
+
import { lookup } from "node:dns/promises";
|
|
7
9
|
|
|
8
10
|
//#region src/cli/repl/command-registry.ts
|
|
9
11
|
const log$1 = logger.child("repl:command-registry");
|
|
@@ -309,7 +311,34 @@ function createStatusCommand() {
|
|
|
309
311
|
* - Ctrl+C: 取消任务 / 二次退出
|
|
310
312
|
* - Ctrl+D: 退出
|
|
311
313
|
* - Escape: 取消当前输入
|
|
314
|
+
*
|
|
315
|
+
* 同时 override render() 以:
|
|
316
|
+
* - 去掉 Editor 默认的上下边框(───)
|
|
317
|
+
* - 添加简洁的输入提示符(❯ )
|
|
318
|
+
* - 执行中时显示 loading spinner
|
|
312
319
|
*/
|
|
320
|
+
/** 输入提示符 */
|
|
321
|
+
const PROMPT_PREFIX = "❯ ";
|
|
322
|
+
/** loading 动画帧 */
|
|
323
|
+
const LOADING_FRAMES = [
|
|
324
|
+
"⠋",
|
|
325
|
+
"⠙",
|
|
326
|
+
"⠹",
|
|
327
|
+
"⠸",
|
|
328
|
+
"⠼",
|
|
329
|
+
"⠴",
|
|
330
|
+
"⠦",
|
|
331
|
+
"⠧",
|
|
332
|
+
"⠇",
|
|
333
|
+
"⠏"
|
|
334
|
+
];
|
|
335
|
+
/** ANSI 转义码正则(用于从渲染行中剥离颜色标记) */
|
|
336
|
+
const ANSI_RE = new RegExp(`\\x1b\\[[0-9;]*m`, "g");
|
|
337
|
+
/** 判断一行是否为 Editor 的 border 行(去除 ANSI 转义码后判断) */
|
|
338
|
+
function isBorderLine(line) {
|
|
339
|
+
const stripped = line.replace(ANSI_RE, "");
|
|
340
|
+
return /^[\s─┌┐├┤└┘↑↓\-0-9a-zA-Z]+$/.test(stripped);
|
|
341
|
+
}
|
|
313
342
|
var ZapmycoEditor = class extends Editor {
|
|
314
343
|
/** Escape 键回调 */
|
|
315
344
|
onEscape;
|
|
@@ -317,6 +346,12 @@ var ZapmycoEditor = class extends Editor {
|
|
|
317
346
|
onCtrlC;
|
|
318
347
|
/** Ctrl+D 回调 */
|
|
319
348
|
onCtrlD;
|
|
349
|
+
/** 是否正在执行(用于显示 loading) */
|
|
350
|
+
#executing = false;
|
|
351
|
+
/** loading 动画帧索引 */
|
|
352
|
+
#loadingFrame = 0;
|
|
353
|
+
/** loading 动画定时器 */
|
|
354
|
+
#loadingTimer;
|
|
320
355
|
handleInput(data) {
|
|
321
356
|
if (matchesKey(data, Key.escape) && this.onEscape) {
|
|
322
357
|
this.onEscape();
|
|
@@ -332,6 +367,51 @@ var ZapmycoEditor = class extends Editor {
|
|
|
332
367
|
}
|
|
333
368
|
super.handleInput(data);
|
|
334
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* 设置执行状态(控制 loading spinner 显示)
|
|
372
|
+
*/
|
|
373
|
+
setExecuting(executing) {
|
|
374
|
+
if (this.#executing === executing) return;
|
|
375
|
+
this.#executing = executing;
|
|
376
|
+
if (executing) {
|
|
377
|
+
this.#loadingFrame = 0;
|
|
378
|
+
this.#loadingTimer = setInterval(() => {
|
|
379
|
+
this.#loadingFrame = (this.#loadingFrame + 1) % LOADING_FRAMES.length;
|
|
380
|
+
this.tui?.requestRender();
|
|
381
|
+
}, 100);
|
|
382
|
+
} else if (this.#loadingTimer) {
|
|
383
|
+
clearInterval(this.#loadingTimer);
|
|
384
|
+
this.#loadingTimer = void 0;
|
|
385
|
+
}
|
|
386
|
+
this.tui?.requestRender();
|
|
387
|
+
}
|
|
388
|
+
get executing() {
|
|
389
|
+
return this.#executing;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Override render(): 去掉默认的上下边框,添加输入提示符和 loading 状态。
|
|
393
|
+
*
|
|
394
|
+
* 策略:调用 super.render() 获取完整的带边框输出,
|
|
395
|
+
* 然后移除首尾 border 行,再在内容行前添加提示符前缀。
|
|
396
|
+
*/
|
|
397
|
+
render(width) {
|
|
398
|
+
const rawLines = super.render(width);
|
|
399
|
+
if (rawLines.length < 3) return rawLines;
|
|
400
|
+
let startIndex = 0;
|
|
401
|
+
if (isBorderLine(rawLines[0] ?? "")) startIndex = 1;
|
|
402
|
+
let endIndex = rawLines.length;
|
|
403
|
+
if (isBorderLine(rawLines[rawLines.length - 1] ?? "")) endIndex = rawLines.length - 1;
|
|
404
|
+
const contentLines = rawLines.slice(startIndex, endIndex);
|
|
405
|
+
const promptWidth = [...PROMPT_PREFIX].length;
|
|
406
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
407
|
+
const prefix = i === 0 ? PROMPT_PREFIX : " ".repeat(promptWidth);
|
|
408
|
+
let line;
|
|
409
|
+
if (i === 0 && this.#executing) line = `${prefix}${LOADING_FRAMES[this.#loadingFrame]} ${contentLines[i]}`;
|
|
410
|
+
else line = prefix + contentLines[i];
|
|
411
|
+
contentLines[i] = truncateToWidth(line, width);
|
|
412
|
+
}
|
|
413
|
+
return contentLines;
|
|
414
|
+
}
|
|
335
415
|
};
|
|
336
416
|
|
|
337
417
|
//#endregion
|
|
@@ -748,16 +828,1045 @@ var Renderer = class {
|
|
|
748
828
|
}
|
|
749
829
|
};
|
|
750
830
|
|
|
831
|
+
//#endregion
|
|
832
|
+
//#region src/cli/repl/tools/cache.ts
|
|
833
|
+
const DEFAULT_TTL_MS = 900 * 1e3;
|
|
834
|
+
const DEFAULT_MAX_ENTRIES = 100;
|
|
835
|
+
/**
|
|
836
|
+
* 创建内存缓存实例
|
|
837
|
+
*
|
|
838
|
+
* @param options - 缓存配置
|
|
839
|
+
* @returns 缓存操作对象
|
|
840
|
+
*/
|
|
841
|
+
function createCache(options = {}) {
|
|
842
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
843
|
+
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
844
|
+
const store = /* @__PURE__ */ new Map();
|
|
845
|
+
return {
|
|
846
|
+
/**
|
|
847
|
+
* 读取缓存
|
|
848
|
+
*
|
|
849
|
+
* @param key - 缓存 key
|
|
850
|
+
* @returns 命中返回 { value, cached: true },未命中返回 null
|
|
851
|
+
*/
|
|
852
|
+
get(key) {
|
|
853
|
+
const entry = store.get(key);
|
|
854
|
+
if (!entry) return null;
|
|
855
|
+
if (Date.now() > entry.expiresAt) {
|
|
856
|
+
store.delete(key);
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
value: entry.value,
|
|
861
|
+
cached: true
|
|
862
|
+
};
|
|
863
|
+
},
|
|
864
|
+
/**
|
|
865
|
+
* 写入缓存
|
|
866
|
+
*
|
|
867
|
+
* @param key - 缓存 key
|
|
868
|
+
* @param value - 要缓存的值
|
|
869
|
+
* @param customTtlMs - 自定义 TTL(覆盖默认值)
|
|
870
|
+
*/
|
|
871
|
+
set(key, value, customTtlMs) {
|
|
872
|
+
const effectiveTtl = customTtlMs ?? ttlMs;
|
|
873
|
+
if (effectiveTtl <= 0) return;
|
|
874
|
+
if (store.size >= maxEntries && !store.has(key)) {
|
|
875
|
+
const oldestKey = store.keys().next().value;
|
|
876
|
+
if (oldestKey !== void 0) store.delete(oldestKey);
|
|
877
|
+
}
|
|
878
|
+
store.set(key, {
|
|
879
|
+
value,
|
|
880
|
+
expiresAt: Date.now() + effectiveTtl,
|
|
881
|
+
insertedAt: Date.now()
|
|
882
|
+
});
|
|
883
|
+
},
|
|
884
|
+
/**
|
|
885
|
+
* 删除指定 key 的缓存
|
|
886
|
+
*/
|
|
887
|
+
delete(key) {
|
|
888
|
+
return store.delete(key);
|
|
889
|
+
},
|
|
890
|
+
/**
|
|
891
|
+
* 清空所有缓存
|
|
892
|
+
*/
|
|
893
|
+
clear() {
|
|
894
|
+
store.clear();
|
|
895
|
+
},
|
|
896
|
+
/**
|
|
897
|
+
* 获取当前缓存条目数
|
|
898
|
+
*/
|
|
899
|
+
get size() {
|
|
900
|
+
return store.size;
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
//#endregion
|
|
906
|
+
//#region src/cli/repl/tools/html-to-markdown.ts
|
|
907
|
+
/**
|
|
908
|
+
* HTML → Markdown 转换模块
|
|
909
|
+
*
|
|
910
|
+
* 使用 turndown 库进行转换,附带启发式正文提取。
|
|
911
|
+
*
|
|
912
|
+
* 参考实现: Claude Code WebFetchTool (turndown 封装模式)
|
|
913
|
+
*
|
|
914
|
+
* @module cli/repl/tools/html-to-markdown
|
|
915
|
+
*/
|
|
916
|
+
let turndownInstance = null;
|
|
917
|
+
/**
|
|
918
|
+
* 获取 turndown 实例(懒加载单例)
|
|
919
|
+
*/
|
|
920
|
+
function getTurndown() {
|
|
921
|
+
if (!turndownInstance) turndownInstance = new TurndownService({
|
|
922
|
+
headingStyle: "atx",
|
|
923
|
+
codeBlockStyle: "fenced",
|
|
924
|
+
bulletListMarker: "-",
|
|
925
|
+
linkStyle: "inlined"
|
|
926
|
+
});
|
|
927
|
+
return turndownInstance;
|
|
928
|
+
}
|
|
929
|
+
/** 正文提取候选选择器(按优先级排序) */
|
|
930
|
+
const MAIN_CONTENT_SELECTORS = [
|
|
931
|
+
"main",
|
|
932
|
+
"article",
|
|
933
|
+
"[role=\"main\"]",
|
|
934
|
+
".content",
|
|
935
|
+
".article",
|
|
936
|
+
".post",
|
|
937
|
+
".entry",
|
|
938
|
+
"#content",
|
|
939
|
+
"#article",
|
|
940
|
+
"#post",
|
|
941
|
+
"#main"
|
|
942
|
+
];
|
|
943
|
+
/**
|
|
944
|
+
* 从 HTML 中启发式提取正文内容
|
|
945
|
+
*/
|
|
946
|
+
function extractMainContent(html) {
|
|
947
|
+
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? "";
|
|
948
|
+
const description = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)/i)?.[1]?.trim() ?? "";
|
|
949
|
+
for (const selector of MAIN_CONTENT_SELECTORS) {
|
|
950
|
+
const content = extractBySelector(html, selector);
|
|
951
|
+
if (content != null && content.length > 100) return buildOutput(title, description, content);
|
|
952
|
+
}
|
|
953
|
+
return buildOutput(title, description, html.match(/<body[^>]*>([\s\S]*)<\/body>/i)?.[1] ?? html);
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* 用正则从 HTML 中提取匹配选择器的元素内容
|
|
957
|
+
*/
|
|
958
|
+
function extractBySelector(html, selector) {
|
|
959
|
+
if (selector.startsWith("#")) {
|
|
960
|
+
const id = selector.slice(1);
|
|
961
|
+
const regex = new RegExp(`<[^>]+id=["']${escapeRegex(id)}["'][^>]*>([\\s\\S]*?)</\\s*\\1>`, "i");
|
|
962
|
+
return html.match(regex)?.[1] ?? null;
|
|
963
|
+
}
|
|
964
|
+
if (selector.match(/^([a-z][\w-]*)$/i)) {
|
|
965
|
+
const regex = new RegExp(`<${escapeRegex(selector)}[^>]*>([\\s\\S]*?)<\\/\\s*${escapeRegex(selector)}>`, "i");
|
|
966
|
+
return html.match(regex)?.[1] ?? null;
|
|
967
|
+
}
|
|
968
|
+
const classMatch = selector.match(/^\.([\w-]+)$/);
|
|
969
|
+
if (classMatch?.[1]) {
|
|
970
|
+
const cls = classMatch[1];
|
|
971
|
+
const regex = new RegExp(`<[^>]+class=["'][^"']*${escapeRegex(cls)}[^"']*["'][^>]*>([\\s\\S]*?)<\\/[^>]+>`, "i");
|
|
972
|
+
return html.match(regex)?.[1] ?? null;
|
|
973
|
+
}
|
|
974
|
+
const roleMatch = selector.match(/^\[role=["'](\w+)["']\]$/);
|
|
975
|
+
if (roleMatch?.[1]) {
|
|
976
|
+
const role = roleMatch[1];
|
|
977
|
+
const regex = new RegExp(`<[^>]+role=["']${escapeRegex(role)}["'][^>]*>([\\s\\S]*?)<\\/[^>]+>`, "i");
|
|
978
|
+
return html.match(regex)?.[1] ?? null;
|
|
979
|
+
}
|
|
980
|
+
return null;
|
|
981
|
+
}
|
|
982
|
+
/** 转义正则特殊字符 */
|
|
983
|
+
function escapeRegex(str) {
|
|
984
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* 组装最终输出
|
|
988
|
+
*/
|
|
989
|
+
function buildOutput(title, description, content) {
|
|
990
|
+
let result = "";
|
|
991
|
+
if (title) result += `# ${title}\n\n`;
|
|
992
|
+
if (description) result += `> ${description}\n\n`;
|
|
993
|
+
result += content;
|
|
994
|
+
return result;
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* 将 HTML 转换为 Markdown
|
|
998
|
+
*/
|
|
999
|
+
function htmlToMarkdown(html) {
|
|
1000
|
+
return getTurndown().turndown(html);
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* 提取正文并转换为 Markdown
|
|
1004
|
+
*/
|
|
1005
|
+
function extractAndConvert(html) {
|
|
1006
|
+
return htmlToMarkdown(extractMainContent(html));
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
//#endregion
|
|
1010
|
+
//#region src/cli/repl/tools/ssrf-guard.ts
|
|
1011
|
+
/**
|
|
1012
|
+
* SSRF(服务器端请求伪造)防护模块
|
|
1013
|
+
*
|
|
1014
|
+
* 三层防护策略:
|
|
1015
|
+
* 1. URL 解析层 — hostname 黑名单匹配
|
|
1016
|
+
* 2. DNS 解析层 — DNS 查询后检查 IP 是否属于保留地址段
|
|
1017
|
+
* 3. 协议层 — 仅允许 http/https
|
|
1018
|
+
*
|
|
1019
|
+
* 参考实现: OpenClaw src/infra/net/ssrf.ts
|
|
1020
|
+
*
|
|
1021
|
+
* @module cli/repl/tools/ssrf-guard
|
|
1022
|
+
*/
|
|
1023
|
+
/** 默认被阻止的 hostname */
|
|
1024
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
1025
|
+
"localhost",
|
|
1026
|
+
"localhost.localdomain",
|
|
1027
|
+
"ip6-localhost",
|
|
1028
|
+
"ip6-loopback",
|
|
1029
|
+
"metadata.google.internal"
|
|
1030
|
+
]);
|
|
1031
|
+
/**
|
|
1032
|
+
* IPv4/IPv6 保留和特殊用途地址段 (CIDR)
|
|
1033
|
+
*
|
|
1034
|
+
* 格式: [networkAddress, prefixBits]
|
|
1035
|
+
*/
|
|
1036
|
+
const PRIVATE_IP_RANGES = [
|
|
1037
|
+
["127.0.0.0", 8],
|
|
1038
|
+
["10.0.0.0", 8],
|
|
1039
|
+
["172.16.0.0", 12],
|
|
1040
|
+
["192.168.0.0", 16],
|
|
1041
|
+
["169.254.0.0", 16],
|
|
1042
|
+
["100.64.0.0", 10],
|
|
1043
|
+
["198.18.0.0", 15],
|
|
1044
|
+
["0.0.0.0", 8],
|
|
1045
|
+
["::1", 128],
|
|
1046
|
+
["fc00::", 7],
|
|
1047
|
+
["fe80::", 10],
|
|
1048
|
+
["::ffff:0:0", 96],
|
|
1049
|
+
["64:ff9b::", 96]
|
|
1050
|
+
];
|
|
1051
|
+
/**
|
|
1052
|
+
* 检查 hostname 是否在黑名单中
|
|
1053
|
+
*/
|
|
1054
|
+
function isBlockedHostname(hostname) {
|
|
1055
|
+
const lower = hostname.toLowerCase();
|
|
1056
|
+
if (BLOCKED_HOSTNAMES.has(lower)) return true;
|
|
1057
|
+
if (lower.endsWith(".localhost") || lower.endsWith(".local") || lower.endsWith(".internal")) return true;
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* 通配符域名匹配
|
|
1062
|
+
*
|
|
1063
|
+
* 支持 *.example.com 格式
|
|
1064
|
+
*/
|
|
1065
|
+
function matchDomainPattern(hostname, pattern) {
|
|
1066
|
+
if (pattern.startsWith("*.")) {
|
|
1067
|
+
const suffix = pattern.slice(2);
|
|
1068
|
+
if (!suffix || hostname === suffix) return false;
|
|
1069
|
+
return hostname === suffix || hostname.endsWith(`.${suffix}`);
|
|
1070
|
+
}
|
|
1071
|
+
return hostname === pattern;
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* 检查 hostname 是否匹配白名单/黑名单
|
|
1075
|
+
*/
|
|
1076
|
+
function checkHostnamePolicy(hostname, options) {
|
|
1077
|
+
if (isBlockedHostname(hostname)) return {
|
|
1078
|
+
allowed: false,
|
|
1079
|
+
reason: `被阻止的 hostname: ${hostname}`
|
|
1080
|
+
};
|
|
1081
|
+
const allowedList = options.allowedDomains ?? [];
|
|
1082
|
+
const blockedList = options.blockedDomains ?? [];
|
|
1083
|
+
for (const pattern of blockedList) if (matchDomainPattern(hostname, pattern)) return {
|
|
1084
|
+
allowed: false,
|
|
1085
|
+
reason: `域名黑名单匹配: ${hostname} 匹配 ${pattern}`
|
|
1086
|
+
};
|
|
1087
|
+
if (allowedList.length > 0) {
|
|
1088
|
+
if (!allowedList.some((pattern) => matchDomainPattern(hostname, pattern))) return {
|
|
1089
|
+
allowed: false,
|
|
1090
|
+
reason: `不在域名白名单中: ${hostname}`
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
return { allowed: true };
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* 将 IPv4 地址字符串转为 32 位整数
|
|
1097
|
+
*/
|
|
1098
|
+
function ipv4ToInt(ip) {
|
|
1099
|
+
const parts = ip.split(".");
|
|
1100
|
+
if (parts.length !== 4) return null;
|
|
1101
|
+
let result = 0;
|
|
1102
|
+
for (const part of parts) {
|
|
1103
|
+
const num = parseInt(part, 10);
|
|
1104
|
+
if (Number.isNaN(num) || num < 0 || num > 255) return null;
|
|
1105
|
+
result = result << 8 | num;
|
|
1106
|
+
}
|
|
1107
|
+
return result >>> 0;
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* 检查 IP 地址是否在给定的 CIDR 范围内
|
|
1111
|
+
*/
|
|
1112
|
+
function ipInCidr(ip, cidr) {
|
|
1113
|
+
const parts = cidr.split("/");
|
|
1114
|
+
if (parts.length < 2) return false;
|
|
1115
|
+
const range = parts[0] ?? "";
|
|
1116
|
+
const prefix = parseInt(parts[1] ?? "32", 10);
|
|
1117
|
+
if (Number.isNaN(prefix)) return false;
|
|
1118
|
+
if (ip.includes(":")) try {
|
|
1119
|
+
const ipBigInt = parseIpv6ToBigInt(ip);
|
|
1120
|
+
const rangeBigInt = parseIpv6ToBigInt(range);
|
|
1121
|
+
if (ipBigInt === null || rangeBigInt === null) return false;
|
|
1122
|
+
const mask = prefix === 0 ? 0n : 2n ** BigInt(128) - 1n << BigInt(128 - prefix);
|
|
1123
|
+
return (ipBigInt & mask) === (rangeBigInt & mask);
|
|
1124
|
+
} catch {
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
const ipInt = ipv4ToInt(ip);
|
|
1128
|
+
if (ipInt === null) return false;
|
|
1129
|
+
const rangeInt = ipv4ToInt(range);
|
|
1130
|
+
if (rangeInt === null) return false;
|
|
1131
|
+
if (prefix === 0) return true;
|
|
1132
|
+
const mask = prefix >= 32 ? 4294967295 : -1 << 32 - prefix >>> 0;
|
|
1133
|
+
return (ipInt & mask) === (rangeInt & mask);
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* 简化的 IPv6 → BigInt 转换(处理标准格式)
|
|
1137
|
+
*/
|
|
1138
|
+
function parseIpv6ToBigInt(ip) {
|
|
1139
|
+
try {
|
|
1140
|
+
const parts = (ip.includes("::") ? ip.replace("::", `::${":".repeat(ip.split(":").length < 8 ? 8 - ip.split(":").length : 0)}`) : ip).split(":");
|
|
1141
|
+
if (parts.length !== 8) return null;
|
|
1142
|
+
let result = 0n;
|
|
1143
|
+
for (const part of parts) {
|
|
1144
|
+
const value = part === "" ? 0 : parseInt(part, 16);
|
|
1145
|
+
if (Number.isNaN(value)) return null;
|
|
1146
|
+
result = (result << 16n) + BigInt(value);
|
|
1147
|
+
}
|
|
1148
|
+
return result;
|
|
1149
|
+
} catch {
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* 检查 IP 地址是否为私有/保留地址
|
|
1155
|
+
*/
|
|
1156
|
+
function isPrivateIp(ip) {
|
|
1157
|
+
for (const entry of PRIVATE_IP_RANGES) {
|
|
1158
|
+
const range = entry[0];
|
|
1159
|
+
const prefix = entry[1] ?? 32;
|
|
1160
|
+
if (range && ipInCidr(ip, `${range}/${String(prefix)}`)) return true;
|
|
1161
|
+
}
|
|
1162
|
+
return false;
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* 检查 DNS 解析结果中的 IP 是否全部为公网地址
|
|
1166
|
+
*/
|
|
1167
|
+
async function checkResolvedAddresses(addresses, options) {
|
|
1168
|
+
if (options.allowPrivateNetwork) return { allowed: true };
|
|
1169
|
+
for (const addr of addresses) if (isPrivateIp(addr)) return {
|
|
1170
|
+
allowed: false,
|
|
1171
|
+
reason: `DNS 解析到私有/保留 IP 地址: ${addr}(可能为 SSRF 攻击)`
|
|
1172
|
+
};
|
|
1173
|
+
return { allowed: true };
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* 检查 URL 是否可安全访问(SSRF 防护)
|
|
1177
|
+
*
|
|
1178
|
+
* @param url - 要检查的 URL
|
|
1179
|
+
* @param options - 防护选项
|
|
1180
|
+
* @returns 检查结果
|
|
1181
|
+
*/
|
|
1182
|
+
async function checkUrlSafety(url, options = {}) {
|
|
1183
|
+
let parsed;
|
|
1184
|
+
try {
|
|
1185
|
+
parsed = new URL(url);
|
|
1186
|
+
} catch {
|
|
1187
|
+
return {
|
|
1188
|
+
allowed: false,
|
|
1189
|
+
reason: `无效的 URL: ${url}`
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
if (!["http:", "https:"].includes(parsed.protocol)) return {
|
|
1193
|
+
allowed: false,
|
|
1194
|
+
reason: `不允许的协议: ${parsed.protocol}(仅支持 http/https)`
|
|
1195
|
+
};
|
|
1196
|
+
const hostname = parsed.hostname;
|
|
1197
|
+
const hostnameResult = checkHostnamePolicy(hostname, options);
|
|
1198
|
+
if (!hostnameResult.allowed) return hostnameResult;
|
|
1199
|
+
try {
|
|
1200
|
+
const rawAddress = (await lookup(hostname, { verbatim: true })).address;
|
|
1201
|
+
const ipResult = await checkResolvedAddresses(Array.isArray(rawAddress) ? rawAddress : [rawAddress], options);
|
|
1202
|
+
if (!ipResult.allowed) return ipResult;
|
|
1203
|
+
} catch (_err) {
|
|
1204
|
+
if (!options.allowPrivateNetwork) return {
|
|
1205
|
+
allowed: false,
|
|
1206
|
+
reason: `DNS 解析失败且不允许私有网络访问: ${hostname}`
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
return { allowed: true };
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
//#endregion
|
|
1213
|
+
//#region src/cli/repl/tools/web-fetch.ts
|
|
1214
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
1215
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 512e3;
|
|
1216
|
+
const DEFAULT_MAX_CHARS = 2e4;
|
|
1217
|
+
const DEFAULT_USER_AGENT = "ZapmycoBot/0.2 (https://github.com/shenjingnan/zapmyco)";
|
|
1218
|
+
const DEFAULT_MAX_REDIRECTS = 3;
|
|
1219
|
+
let fetchCache = null;
|
|
1220
|
+
function getFetchCache(ttlMinutes = 15) {
|
|
1221
|
+
if (!fetchCache) fetchCache = createCache({
|
|
1222
|
+
ttlMs: ttlMinutes * 60 * 1e3,
|
|
1223
|
+
maxEntries: 100
|
|
1224
|
+
});
|
|
1225
|
+
return fetchCache;
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* 构建缓存 key
|
|
1229
|
+
*/
|
|
1230
|
+
function buildCacheKey$1(url, extractMain, maxChars) {
|
|
1231
|
+
return `fetch:${url}:${extractMain ? "main" : "full"}:${maxChars}`;
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* 截断文本到指定字符数
|
|
1235
|
+
*/
|
|
1236
|
+
function truncateText(text, maxChars) {
|
|
1237
|
+
if (text.length <= maxChars) return {
|
|
1238
|
+
text,
|
|
1239
|
+
truncated: false
|
|
1240
|
+
};
|
|
1241
|
+
const lastSpace = text.slice(0, maxChars).lastIndexOf(" ");
|
|
1242
|
+
const cutAt = lastSpace > maxChars * .8 ? lastSpace : maxChars;
|
|
1243
|
+
return {
|
|
1244
|
+
text: `${text.slice(0, cutAt)}\n\n... [内容已截断]`,
|
|
1245
|
+
truncated: true
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* 根据 Content-Type 判断响应类型
|
|
1250
|
+
*/
|
|
1251
|
+
function getContentType(headers) {
|
|
1252
|
+
return headers.get("content-type") ?? "application/octet-stream";
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* 判断是否为 HTML 内容
|
|
1256
|
+
*/
|
|
1257
|
+
function isHtmlContent(contentType) {
|
|
1258
|
+
return contentType.toLowerCase().includes("text/html");
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* 判断是否为 JSON 内容
|
|
1262
|
+
*/
|
|
1263
|
+
function isJsonContent(contentType) {
|
|
1264
|
+
return contentType.toLowerCase().includes("application/json");
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* 执行 HTTP 抓取(含重定向跟踪)
|
|
1268
|
+
*/
|
|
1269
|
+
async function doFetch(url, options, ssrfOptions, signal) {
|
|
1270
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1271
|
+
const maxBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
1272
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
1273
|
+
const userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
|
|
1274
|
+
const controller = new AbortController();
|
|
1275
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
1276
|
+
signal?.addEventListener("abort", () => controller.abort(), { once: true });
|
|
1277
|
+
try {
|
|
1278
|
+
let currentUrl = url;
|
|
1279
|
+
let redirectCount = 0;
|
|
1280
|
+
while (redirectCount <= maxRedirects) {
|
|
1281
|
+
const response = await fetch(currentUrl, {
|
|
1282
|
+
method: "GET",
|
|
1283
|
+
headers: { "User-Agent": userAgent },
|
|
1284
|
+
signal: controller.signal,
|
|
1285
|
+
redirect: "manual"
|
|
1286
|
+
});
|
|
1287
|
+
if ([
|
|
1288
|
+
301,
|
|
1289
|
+
302,
|
|
1290
|
+
303,
|
|
1291
|
+
307,
|
|
1292
|
+
308
|
|
1293
|
+
].includes(response.status)) {
|
|
1294
|
+
const location = response.headers.get("location");
|
|
1295
|
+
if (!location) throw new WebError("WEB_FETCH_FAILED", `重定向但无 Location 头: ${currentUrl}`);
|
|
1296
|
+
currentUrl = new URL(location, currentUrl).href;
|
|
1297
|
+
const safetyResult = await checkUrlSafety(currentUrl, ssrfOptions);
|
|
1298
|
+
if (!safetyResult.allowed) throw new WebError("WEB_FETCH_BLOCKED", safetyResult.reason ?? `重定向目标 URL 未通过安全检查: ${currentUrl}`, {
|
|
1299
|
+
url: currentUrl,
|
|
1300
|
+
reason: safetyResult.reason
|
|
1301
|
+
});
|
|
1302
|
+
redirectCount++;
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
if (!response.ok) throw new WebError("WEB_FETCH_FAILED", `HTTP ${response.status}: ${currentUrl}`, {
|
|
1306
|
+
statusCode: response.status,
|
|
1307
|
+
url: currentUrl
|
|
1308
|
+
});
|
|
1309
|
+
const contentLength = parseInt(response.headers.get("content-length") ?? "0", 10);
|
|
1310
|
+
if (contentLength > maxBytes) throw new WebError("WEB_FETCH_TOO_LARGE", `响应过大: ${contentLength} 字节(限制 ${maxBytes})`, {
|
|
1311
|
+
contentLength,
|
|
1312
|
+
maxBytes,
|
|
1313
|
+
url: currentUrl
|
|
1314
|
+
});
|
|
1315
|
+
const buffer = await response.arrayBuffer();
|
|
1316
|
+
if (buffer.byteLength > maxBytes) throw new WebError("WEB_FETCH_TOO_LARGE", `响应体过大: ${buffer.byteLength} 字节(限制 ${maxBytes})`, {
|
|
1317
|
+
actualSize: buffer.byteLength,
|
|
1318
|
+
maxBytes,
|
|
1319
|
+
url: currentUrl
|
|
1320
|
+
});
|
|
1321
|
+
return {
|
|
1322
|
+
buffer,
|
|
1323
|
+
contentType: getContentType(response.headers),
|
|
1324
|
+
statusCode: response.status,
|
|
1325
|
+
finalUrl: currentUrl
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
throw new WebError("WEB_FETCH_FAILED", `重定向次数超过上限 (${maxRedirects})`, {
|
|
1329
|
+
maxRedirects,
|
|
1330
|
+
url
|
|
1331
|
+
});
|
|
1332
|
+
} catch (err) {
|
|
1333
|
+
if (err instanceof WebError) throw err;
|
|
1334
|
+
if (err instanceof DOMException && err.name === "AbortError") throw new WebError("WEB_FETCH_TIMEOUT", `请求超时 (${timeoutMs}ms): ${url}`, {
|
|
1335
|
+
timeoutMs,
|
|
1336
|
+
url
|
|
1337
|
+
});
|
|
1338
|
+
throw new WebError("WEB_FETCH_FAILED", `抓取失败: ${err instanceof Error ? err.message : String(err)}`, { url });
|
|
1339
|
+
} finally {
|
|
1340
|
+
clearTimeout(timeoutId);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* 处理响应内容并转换为文本
|
|
1345
|
+
*/
|
|
1346
|
+
function processResponse(buffer, contentType, extractMain) {
|
|
1347
|
+
const rawText = new TextDecoder("utf-8", { fatal: false }).decode(buffer);
|
|
1348
|
+
if (isHtmlContent(contentType)) {
|
|
1349
|
+
if (extractMain) return {
|
|
1350
|
+
text: extractAndConvert(rawText),
|
|
1351
|
+
extractionMethod: "main-content"
|
|
1352
|
+
};
|
|
1353
|
+
return {
|
|
1354
|
+
text: htmlToMarkdown(rawText),
|
|
1355
|
+
extractionMethod: "full"
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
if (isJsonContent(contentType)) try {
|
|
1359
|
+
const parsed = JSON.parse(rawText);
|
|
1360
|
+
return {
|
|
1361
|
+
text: JSON.stringify(parsed, null, 2),
|
|
1362
|
+
extractionMethod: "raw"
|
|
1363
|
+
};
|
|
1364
|
+
} catch {}
|
|
1365
|
+
return {
|
|
1366
|
+
text: rawText,
|
|
1367
|
+
extractionMethod: "raw"
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* 创建 web_fetch 工具注册信息
|
|
1372
|
+
*/
|
|
1373
|
+
function createWebFetchTool(webConfig) {
|
|
1374
|
+
const fetchOptions = webConfig?.fetch ?? {};
|
|
1375
|
+
const ssrfOptions = webConfig?.ssrf ?? {};
|
|
1376
|
+
const cacheTtlMinutes = fetchOptions.cacheTtlMinutes ?? 15;
|
|
1377
|
+
return {
|
|
1378
|
+
id: "web_fetch",
|
|
1379
|
+
label: "网页抓取",
|
|
1380
|
+
description: "抓取指定 URL 的网页内容并转换为 Markdown 格式。支持 HTML 正文提取、JSON 美化、内容截断。当用户需要访问网页获取信息时调用此工具。",
|
|
1381
|
+
parameters: {
|
|
1382
|
+
type: "object",
|
|
1383
|
+
properties: {
|
|
1384
|
+
url: {
|
|
1385
|
+
type: "string",
|
|
1386
|
+
description: "要抓取的 URL(必须 http:// 或 https:// 开头)"
|
|
1387
|
+
},
|
|
1388
|
+
extractMainContent: {
|
|
1389
|
+
type: "boolean",
|
|
1390
|
+
description: "是否只提取正文内容(默认根据全局配置)"
|
|
1391
|
+
},
|
|
1392
|
+
maxChars: {
|
|
1393
|
+
type: "number",
|
|
1394
|
+
description: "返回内容的最大字符数(默认使用全局配置,最大 100000)"
|
|
1395
|
+
}
|
|
1396
|
+
},
|
|
1397
|
+
required: ["url"]
|
|
1398
|
+
},
|
|
1399
|
+
async execute(_toolCallId, params, signal) {
|
|
1400
|
+
const startTime = Date.now();
|
|
1401
|
+
const { url } = params;
|
|
1402
|
+
const extractMain = params.extractMainContent ?? fetchOptions.extractMainContent ?? true;
|
|
1403
|
+
const maxChars = Math.min(params.maxChars ?? fetchOptions.maxChars ?? DEFAULT_MAX_CHARS, 1e5);
|
|
1404
|
+
const cache = getFetchCache(cacheTtlMinutes);
|
|
1405
|
+
const cacheKey = buildCacheKey$1(url, extractMain, maxChars);
|
|
1406
|
+
const cached = cache.get(cacheKey);
|
|
1407
|
+
if (cached) return {
|
|
1408
|
+
content: [{
|
|
1409
|
+
type: "text",
|
|
1410
|
+
text: cached.value
|
|
1411
|
+
}],
|
|
1412
|
+
details: {
|
|
1413
|
+
url,
|
|
1414
|
+
statusCode: 200,
|
|
1415
|
+
contentLength: cached.value.length,
|
|
1416
|
+
truncated: false,
|
|
1417
|
+
extractionMethod: "full",
|
|
1418
|
+
cached: true,
|
|
1419
|
+
elapsedMs: Date.now() - startTime
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
const safetyResult = await checkUrlSafety(url, ssrfOptions);
|
|
1423
|
+
if (!safetyResult.allowed) throw new WebError("WEB_FETCH_BLOCKED", safetyResult.reason ?? "URL 未通过安全检查", {
|
|
1424
|
+
url,
|
|
1425
|
+
reason: safetyResult.reason
|
|
1426
|
+
});
|
|
1427
|
+
const { buffer, contentType, statusCode, finalUrl } = await doFetch(url, fetchOptions, ssrfOptions, signal);
|
|
1428
|
+
const { text: rawOutput, extractionMethod } = processResponse(buffer, contentType, extractMain);
|
|
1429
|
+
const { text, truncated } = truncateText(rawOutput, maxChars);
|
|
1430
|
+
cache.set(cacheKey, text);
|
|
1431
|
+
const details = {
|
|
1432
|
+
url,
|
|
1433
|
+
...finalUrl !== url ? { finalUrl } : {},
|
|
1434
|
+
statusCode,
|
|
1435
|
+
contentType,
|
|
1436
|
+
contentLength: text.length,
|
|
1437
|
+
truncated,
|
|
1438
|
+
extractionMethod,
|
|
1439
|
+
cached: false,
|
|
1440
|
+
elapsedMs: Date.now() - startTime
|
|
1441
|
+
};
|
|
1442
|
+
return {
|
|
1443
|
+
content: [{
|
|
1444
|
+
type: "text",
|
|
1445
|
+
text
|
|
1446
|
+
}],
|
|
1447
|
+
details
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
//#endregion
|
|
1454
|
+
//#region src/cli/repl/tools/search-providers.ts
|
|
1455
|
+
/**
|
|
1456
|
+
* 搜索引擎 Provider 插件系统
|
|
1457
|
+
*
|
|
1458
|
+
* 提供插件化搜索引擎接口,内置 4 种实现:
|
|
1459
|
+
* - TavilyProvider — 默认,高质量 AI 搜索(需 API Key)
|
|
1460
|
+
* - SerpApiProvider — Google/Bing 等多引擎(需 API Key)
|
|
1461
|
+
* - DuckDuckGoProvider — HTML 抓取兜底(免费,无需 Key)
|
|
1462
|
+
* - CustomProvider — 用户自建端点
|
|
1463
|
+
*
|
|
1464
|
+
* @module cli/repl/tools/search-providers
|
|
1465
|
+
*/
|
|
1466
|
+
/**
|
|
1467
|
+
* 构建 fetch options(处理 exactOptionalPropertyTypes 下 signal 不可为 undefined 的问题)
|
|
1468
|
+
*
|
|
1469
|
+
* signal 作为独立参数传入,避免对象字面量类型推断问题
|
|
1470
|
+
*/
|
|
1471
|
+
function buildFetchOptions(init, signal) {
|
|
1472
|
+
const opts = {};
|
|
1473
|
+
if (init.method) opts.method = init.method;
|
|
1474
|
+
if (init.headers) opts.headers = init.headers;
|
|
1475
|
+
if (init.body != null) opts.body = init.body;
|
|
1476
|
+
if (signal !== void 0) opts.signal = signal;
|
|
1477
|
+
return opts;
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* 判断 URL 是否为 DuckDuckGo 内部跳转链接
|
|
1481
|
+
*
|
|
1482
|
+
* 使用 URL 构造器解析 hostname,避免子串匹配被绕过
|
|
1483
|
+
*/
|
|
1484
|
+
function isDuckDuckGoInternalUrl(rawUrl) {
|
|
1485
|
+
try {
|
|
1486
|
+
const host = new URL(rawUrl).hostname.toLowerCase();
|
|
1487
|
+
return host === "duckduckgo.com" || host.endsWith(".duckduckgo.com");
|
|
1488
|
+
} catch {
|
|
1489
|
+
return true;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
var TavilyProvider = class {
|
|
1493
|
+
name = "tavily";
|
|
1494
|
+
label = "Tavily";
|
|
1495
|
+
requiresApiKey = true;
|
|
1496
|
+
isAvailable(config) {
|
|
1497
|
+
return !!config.apiKey;
|
|
1498
|
+
}
|
|
1499
|
+
async search(query, options, config, signal) {
|
|
1500
|
+
const apiKey = config.apiKey;
|
|
1501
|
+
if (!apiKey) throw new Error("Tavily 搜索需要 API Key。请配置 web.search.apiKey 或设置 TAVILY_API_KEY 环境变量。\n获取免费 API Key: https://tavily.com");
|
|
1502
|
+
const maxResults = Math.min(options.maxResults, 20);
|
|
1503
|
+
const response = await fetch("https://api.tavily.com/search", buildFetchOptions({
|
|
1504
|
+
method: "POST",
|
|
1505
|
+
headers: { "Content-Type": "application/json" },
|
|
1506
|
+
body: JSON.stringify({
|
|
1507
|
+
api_key: apiKey,
|
|
1508
|
+
query,
|
|
1509
|
+
max_results: maxResults,
|
|
1510
|
+
include_answer: false,
|
|
1511
|
+
search_depth: "basic"
|
|
1512
|
+
})
|
|
1513
|
+
}, signal));
|
|
1514
|
+
if (!response.ok) throw new Error(`Tavily API 错误 (${response.status})`);
|
|
1515
|
+
const data = await response.json();
|
|
1516
|
+
if (!data.results?.length) return [];
|
|
1517
|
+
return data.results.map((r) => ({
|
|
1518
|
+
title: r.title,
|
|
1519
|
+
url: r.url,
|
|
1520
|
+
snippet: r.content.slice(0, 300)
|
|
1521
|
+
}));
|
|
1522
|
+
}
|
|
1523
|
+
};
|
|
1524
|
+
var SerpApiProvider = class {
|
|
1525
|
+
name = "serpapi";
|
|
1526
|
+
label = "SerpAPI";
|
|
1527
|
+
requiresApiKey = true;
|
|
1528
|
+
isAvailable(config) {
|
|
1529
|
+
return !!config.apiKey;
|
|
1530
|
+
}
|
|
1531
|
+
async search(query, options, config, signal) {
|
|
1532
|
+
const apiKey = config.apiKey;
|
|
1533
|
+
if (!apiKey) throw new Error("SerpAPI 搜索需要 API Key。请配置 web.search.apiKey 或设置 SERPAPI_API_KEY 环境变量。\n获取 API Key: https://serpapi.com");
|
|
1534
|
+
const maxResults = Math.min(options.maxResults, 20);
|
|
1535
|
+
const params = new URLSearchParams({
|
|
1536
|
+
api_key: apiKey,
|
|
1537
|
+
q: query,
|
|
1538
|
+
num: String(maxResults),
|
|
1539
|
+
engine: "google",
|
|
1540
|
+
hl: options.language ?? "zh-cn"
|
|
1541
|
+
});
|
|
1542
|
+
const response = await fetch("https://serpapi.com/search", buildFetchOptions({
|
|
1543
|
+
method: "POST",
|
|
1544
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1545
|
+
body: params.toString()
|
|
1546
|
+
}, signal));
|
|
1547
|
+
if (!response.ok) throw new Error(`SerpAPI 错误 (${response.status})`);
|
|
1548
|
+
const data = await response.json();
|
|
1549
|
+
if (data.error) throw new Error(`SerpAPI: ${data.error}`);
|
|
1550
|
+
if (!data.organic_results?.length) return [];
|
|
1551
|
+
return data.organic_results.map((r) => ({
|
|
1552
|
+
title: r.title,
|
|
1553
|
+
url: r.link,
|
|
1554
|
+
snippet: r.snippet.slice(0, 300)
|
|
1555
|
+
}));
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
var DuckDuckGoProvider = class {
|
|
1559
|
+
name = "duckduckgo";
|
|
1560
|
+
label = "DuckDuckGo";
|
|
1561
|
+
requiresApiKey = false;
|
|
1562
|
+
isAvailable() {
|
|
1563
|
+
return true;
|
|
1564
|
+
}
|
|
1565
|
+
async search(query, options, _config, signal) {
|
|
1566
|
+
const maxResults = Math.min(options.maxResults, 20);
|
|
1567
|
+
const response = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, buildFetchOptions({ headers: { "User-Agent": "Mozilla/5.0 (compatible; ZapmycoBot/0.2)" } }, signal));
|
|
1568
|
+
if (!response.ok) throw new Error(`DuckDuckGo 请求失败 (${response.status})`);
|
|
1569
|
+
const html = await response.text();
|
|
1570
|
+
return this.parseDdgHtml(html, maxResults);
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* 解析 DuckDuckGo HTML 结果页面
|
|
1574
|
+
*/
|
|
1575
|
+
parseDdgHtml(html, maxResults) {
|
|
1576
|
+
const results = [];
|
|
1577
|
+
const resultBlocks = html.split(/<div[^>]+class="[^"]*result[^"]*"[^>]*>/i);
|
|
1578
|
+
for (const block of resultBlocks) {
|
|
1579
|
+
if (results.length >= maxResults) break;
|
|
1580
|
+
const titleMatch = block.match(/<a[^>]+class="[^"]*result__title[^"]*"[^>]*>([\s\S]*?)<\/a>/i);
|
|
1581
|
+
const title = titleMatch ? this.stripHtmlTags(titleMatch[1] ?? "").trim() : "";
|
|
1582
|
+
const urlMatch = block.match(/<a[^>]+class="[^"]*result__url[^"]*"[^>]*href="([^"]*)"/i);
|
|
1583
|
+
const url = urlMatch ? this.decodeDdgUrl(urlMatch[1] ?? "") : "";
|
|
1584
|
+
const snippetMatch = block.match(/<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/i);
|
|
1585
|
+
const snippet = snippetMatch ? this.stripHtmlTags(snippetMatch[1] ?? "").trim().slice(0, 300) : "";
|
|
1586
|
+
if (title && url) results.push({
|
|
1587
|
+
title,
|
|
1588
|
+
url,
|
|
1589
|
+
snippet
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
if (results.length === 0) return this.fallbackParseDdgHtml(html, maxResults);
|
|
1593
|
+
return results;
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* 备用解析:更宽松的正则匹配
|
|
1597
|
+
*/
|
|
1598
|
+
fallbackParseDdgHtml(html, maxResults) {
|
|
1599
|
+
const results = [];
|
|
1600
|
+
const linkRegex = /<a[^>]+(class="[^"]*(?:result|snippet)[^"]*")[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
1601
|
+
let match = linkRegex.exec(html);
|
|
1602
|
+
while (match !== null && results.length < maxResults) {
|
|
1603
|
+
const rawUrl = match[2];
|
|
1604
|
+
const rawText = match[3];
|
|
1605
|
+
if (rawUrl == null || rawText == null) continue;
|
|
1606
|
+
if (rawUrl.startsWith("/") || isDuckDuckGoInternalUrl(rawUrl)) continue;
|
|
1607
|
+
const text = this.stripHtmlTags(rawText).trim();
|
|
1608
|
+
if (text.length > 10) results.push({
|
|
1609
|
+
title: text.slice(0, 120),
|
|
1610
|
+
url: this.decodeDdgUrl(rawUrl),
|
|
1611
|
+
snippet: ""
|
|
1612
|
+
});
|
|
1613
|
+
match = linkRegex.exec(html);
|
|
1614
|
+
}
|
|
1615
|
+
return results;
|
|
1616
|
+
}
|
|
1617
|
+
/** 移除 HTML 标签并解码 HTML 实体 */
|
|
1618
|
+
stripHtmlTags(html) {
|
|
1619
|
+
let text = html;
|
|
1620
|
+
text = text.replace(/ /g, " ").replace(/&(amp|lt|gt|quot|#\d+|#x[0-9a-fA-F]+);/g, (_, token) => {
|
|
1621
|
+
const entityMap = {
|
|
1622
|
+
amp: "&",
|
|
1623
|
+
lt: "<",
|
|
1624
|
+
gt: ">",
|
|
1625
|
+
quot: "\""
|
|
1626
|
+
};
|
|
1627
|
+
if (token in entityMap) return entityMap[token];
|
|
1628
|
+
if (token.startsWith("#x")) {
|
|
1629
|
+
const cp = parseInt(token.slice(2), 16);
|
|
1630
|
+
return Number.isNaN(cp) ? _ : String.fromCharCode(cp);
|
|
1631
|
+
}
|
|
1632
|
+
if (token.startsWith("#")) {
|
|
1633
|
+
const cp = parseInt(token.slice(1), 10);
|
|
1634
|
+
return Number.isNaN(cp) ? _ : String.fromCharCode(cp);
|
|
1635
|
+
}
|
|
1636
|
+
return _;
|
|
1637
|
+
});
|
|
1638
|
+
text = text.replace(/[<>]/g, "");
|
|
1639
|
+
return text;
|
|
1640
|
+
}
|
|
1641
|
+
/** 解码 DuckDuckGo 跳转 URL */
|
|
1642
|
+
decodeDdgUrl(url) {
|
|
1643
|
+
try {
|
|
1644
|
+
if (url.includes("uddg=")) {
|
|
1645
|
+
const uddgMatch = url.match(/uddg=([^&]+)/);
|
|
1646
|
+
if (uddgMatch?.[1]) return decodeURIComponent(uddgMatch[1]);
|
|
1647
|
+
}
|
|
1648
|
+
return decodeURIComponent(url);
|
|
1649
|
+
} catch {
|
|
1650
|
+
return url;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
var CustomProvider = class {
|
|
1655
|
+
name = "custom";
|
|
1656
|
+
label = "自定义端点";
|
|
1657
|
+
requiresApiKey = false;
|
|
1658
|
+
isAvailable(config) {
|
|
1659
|
+
return !!config.endpointUrl;
|
|
1660
|
+
}
|
|
1661
|
+
async search(query, options, config, signal) {
|
|
1662
|
+
const endpointUrl = config.endpointUrl;
|
|
1663
|
+
if (!endpointUrl) throw new Error("自定义搜索需要配置 endpointUrl。请在 web.search.endpointUrl 中指定端点地址。");
|
|
1664
|
+
const maxResults = Math.min(options.maxResults, 20);
|
|
1665
|
+
const safetyResult = await checkUrlSafety(endpointUrl);
|
|
1666
|
+
if (!safetyResult.allowed) throw new Error(`自定义搜索端点 URL 未通过安全检查: ${safetyResult.reason ?? endpointUrl}`);
|
|
1667
|
+
const headers = { "Content-Type": "application/json" };
|
|
1668
|
+
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
|
1669
|
+
const response = await fetch(endpointUrl, buildFetchOptions({
|
|
1670
|
+
method: "POST",
|
|
1671
|
+
headers,
|
|
1672
|
+
body: JSON.stringify({
|
|
1673
|
+
query,
|
|
1674
|
+
max_results: maxResults,
|
|
1675
|
+
language: options.language ?? "zh-cn"
|
|
1676
|
+
})
|
|
1677
|
+
}, signal));
|
|
1678
|
+
if (!response.ok) throw new Error(`自定义搜索端点错误 (${response.status}): ${endpointUrl}`);
|
|
1679
|
+
const data = await response.json();
|
|
1680
|
+
if (data.error) throw new Error(`自定义搜索端点返回错误: ${data.error}`);
|
|
1681
|
+
return data.results ?? [];
|
|
1682
|
+
}
|
|
1683
|
+
};
|
|
1684
|
+
const PROVIDER_REGISTRY = new Map([
|
|
1685
|
+
["tavily", () => new TavilyProvider()],
|
|
1686
|
+
["serpapi", () => new SerpApiProvider()],
|
|
1687
|
+
["duckduckgo", () => new DuckDuckGoProvider()],
|
|
1688
|
+
["custom", () => new CustomProvider()]
|
|
1689
|
+
]);
|
|
1690
|
+
/**
|
|
1691
|
+
* 根据 provider 名称解析对应的 Provider 实例
|
|
1692
|
+
*/
|
|
1693
|
+
function resolveSearchProvider(providerName) {
|
|
1694
|
+
const factory = PROVIDER_REGISTRY.get(providerName);
|
|
1695
|
+
if (!factory) {
|
|
1696
|
+
const available = [...PROVIDER_REGISTRY.keys()].join(", ");
|
|
1697
|
+
throw new Error(`未知搜索引擎: "${providerName}",可用的 Provider: ${available}`);
|
|
1698
|
+
}
|
|
1699
|
+
return factory();
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* 获取所有已注册的 Provider 名称列表
|
|
1703
|
+
*/
|
|
1704
|
+
function getAvailableProviders() {
|
|
1705
|
+
return [...PROVIDER_REGISTRY.keys()];
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
//#endregion
|
|
1709
|
+
//#region src/cli/repl/tools/web-search.ts
|
|
1710
|
+
const DEFAULT_MAX_RESULTS = 8;
|
|
1711
|
+
const DEFAULT_LANGUAGE = "zh-cn";
|
|
1712
|
+
let searchCache = null;
|
|
1713
|
+
function getSearchCache(ttlMinutes = 15) {
|
|
1714
|
+
if (!searchCache) searchCache = createCache({
|
|
1715
|
+
ttlMs: ttlMinutes * 60 * 1e3,
|
|
1716
|
+
maxEntries: 100
|
|
1717
|
+
});
|
|
1718
|
+
return searchCache;
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* 构建缓存 key
|
|
1722
|
+
*/
|
|
1723
|
+
function buildCacheKey(query, provider, numResults) {
|
|
1724
|
+
return `search:${query}:${provider}:${numResults}`;
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* 格式化搜索结果为 Markdown 文本
|
|
1728
|
+
*/
|
|
1729
|
+
function formatResults(results, query, provider) {
|
|
1730
|
+
if (results.length === 0) return `未找到与 "${query}" 相关的搜索结果(来源: ${provider})。`;
|
|
1731
|
+
const lines = [`找到 ${results.length} 条搜索结果 (来源: ${provider}):`, ""];
|
|
1732
|
+
for (let i = 0; i < results.length; i++) {
|
|
1733
|
+
const r = results[i];
|
|
1734
|
+
lines.push(`${i + 1}. **${r.title}**`);
|
|
1735
|
+
lines.push(` ${r.url}`);
|
|
1736
|
+
if (r.snippet) lines.push(` ${r.snippet}`);
|
|
1737
|
+
lines.push("");
|
|
1738
|
+
}
|
|
1739
|
+
return lines.join("\n");
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* 创建 web_search 工具注册信息
|
|
1743
|
+
*/
|
|
1744
|
+
function createWebSearchTool(webConfig) {
|
|
1745
|
+
const searchOptions = webConfig?.search ?? {};
|
|
1746
|
+
const cacheTtlMinutes = searchOptions.cacheTtlMinutes ?? 15;
|
|
1747
|
+
return {
|
|
1748
|
+
id: "web_search",
|
|
1749
|
+
label: "网页搜索",
|
|
1750
|
+
description: "在互联网上搜索信息。支持多种搜索引擎后端。当用户需要查找最新信息、技术文档、新闻等时调用此工具。",
|
|
1751
|
+
parameters: {
|
|
1752
|
+
type: "object",
|
|
1753
|
+
properties: {
|
|
1754
|
+
query: {
|
|
1755
|
+
type: "string",
|
|
1756
|
+
description: "搜索关键词或自然语言查询"
|
|
1757
|
+
},
|
|
1758
|
+
numResults: {
|
|
1759
|
+
type: "number",
|
|
1760
|
+
description: "返回结果数量(1-20,默认 8)"
|
|
1761
|
+
}
|
|
1762
|
+
},
|
|
1763
|
+
required: ["query"]
|
|
1764
|
+
},
|
|
1765
|
+
async execute(_toolCallId, params, signal) {
|
|
1766
|
+
const startTime = Date.now();
|
|
1767
|
+
const { query } = params;
|
|
1768
|
+
const numResults = Math.min(params.numResults ?? searchOptions.maxResults ?? DEFAULT_MAX_RESULTS, 20);
|
|
1769
|
+
const providerName = searchOptions.provider ?? "tavily";
|
|
1770
|
+
const language = searchOptions.language ?? DEFAULT_LANGUAGE;
|
|
1771
|
+
const cache = getSearchCache(cacheTtlMinutes);
|
|
1772
|
+
const cacheKey = buildCacheKey(query, providerName, numResults);
|
|
1773
|
+
const cached = cache.get(cacheKey);
|
|
1774
|
+
if (cached) return {
|
|
1775
|
+
content: [{
|
|
1776
|
+
type: "text",
|
|
1777
|
+
text: cached.value
|
|
1778
|
+
}],
|
|
1779
|
+
details: {
|
|
1780
|
+
query,
|
|
1781
|
+
provider: providerName,
|
|
1782
|
+
resultCount: 0,
|
|
1783
|
+
cached: true,
|
|
1784
|
+
elapsedMs: Date.now() - startTime
|
|
1785
|
+
}
|
|
1786
|
+
};
|
|
1787
|
+
let provider;
|
|
1788
|
+
try {
|
|
1789
|
+
provider = resolveSearchProvider(providerName);
|
|
1790
|
+
} catch (err) {
|
|
1791
|
+
throw new WebError("WEB_SEARCH_NOT_CONFIGURED", err instanceof Error ? err.message : String(err), { requestedProvider: providerName });
|
|
1792
|
+
}
|
|
1793
|
+
const providerConfig = {};
|
|
1794
|
+
if (searchOptions.apiKey) providerConfig.apiKey = searchOptions.apiKey;
|
|
1795
|
+
if (searchOptions.endpointUrl) providerConfig.endpointUrl = searchOptions.endpointUrl;
|
|
1796
|
+
if (!await provider.isAvailable(providerConfig)) {
|
|
1797
|
+
const available = getAvailableProviders().filter((name) => name !== providerName).join(", ");
|
|
1798
|
+
if (providerName === "tavily") throw new WebError("WEB_SEARCH_NOT_CONFIGURED", `Tavily 搜索未配置 API Key。
|
|
1799
|
+
|
|
1800
|
+
**解决方案:**
|
|
1801
|
+
1. 获取免费 Tavily API Key: https://tavily.com
|
|
1802
|
+
2. 设置环境变量: export TAVILY_API_KEY=tvly-xxxxx
|
|
1803
|
+
3. 或在配置文件中设置: web.search.apiKey
|
|
1804
|
+
|
|
1805
|
+
**替代方案:** 切换到 DuckDuckGo(免费无需 Key):
|
|
1806
|
+
在配置文件中设置 web.search.provider = "duckduckgo"
|
|
1807
|
+
|
|
1808
|
+
可用搜索引擎: ${available}, ${providerName}`, {
|
|
1809
|
+
requestedProvider: providerName,
|
|
1810
|
+
availableProviders: getAvailableProviders()
|
|
1811
|
+
});
|
|
1812
|
+
throw new WebError("WEB_SEARCH_NOT_CONFIGURED", `${provider.label} 搜索不可用。请检查 API Key 配置。\n\n可用替代引擎: ${available}`, {
|
|
1813
|
+
requestedProvider: providerName,
|
|
1814
|
+
availableProviders: getAvailableProviders()
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
let results;
|
|
1818
|
+
try {
|
|
1819
|
+
results = await provider.search(query, {
|
|
1820
|
+
maxResults: numResults,
|
|
1821
|
+
language
|
|
1822
|
+
}, providerConfig, signal);
|
|
1823
|
+
} catch (err) {
|
|
1824
|
+
if (err instanceof Error && err.message.includes("quota")) throw new WebError("WEB_SEARCH_QUOTA_EXCEEDED", err.message, { provider: providerName });
|
|
1825
|
+
throw new WebError("WEB_SEARCH_FAILED", `搜索失败: ${err instanceof Error ? err.message : String(err)}`, {
|
|
1826
|
+
provider: providerName,
|
|
1827
|
+
query
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
const text = formatResults(results, query, provider.label);
|
|
1831
|
+
cache.set(cacheKey, text);
|
|
1832
|
+
const details = {
|
|
1833
|
+
query,
|
|
1834
|
+
provider: providerName,
|
|
1835
|
+
resultCount: results.length,
|
|
1836
|
+
cached: false,
|
|
1837
|
+
elapsedMs: Date.now() - startTime
|
|
1838
|
+
};
|
|
1839
|
+
return {
|
|
1840
|
+
content: [{
|
|
1841
|
+
type: "text",
|
|
1842
|
+
text
|
|
1843
|
+
}],
|
|
1844
|
+
details
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
|
|
751
1850
|
//#endregion
|
|
752
1851
|
//#region src/cli/repl/repl-agent-tools.ts
|
|
753
1852
|
/**
|
|
1853
|
+
* REPL Agent 工具注册表
|
|
1854
|
+
*
|
|
1855
|
+
* 定义 REPL 场景下 Agent 可用的工具集合。
|
|
1856
|
+
* 工具按能力域分组,支持按需加载。
|
|
1857
|
+
*
|
|
1858
|
+
* @module cli/repl/repl-agent-tools
|
|
1859
|
+
*/
|
|
1860
|
+
/**
|
|
754
1861
|
* 创建 REPL 基础工具集
|
|
755
1862
|
*
|
|
756
1863
|
* 第一阶段工具:验证 Agent 工具调用链路的端到端连通性。
|
|
757
1864
|
* 后续阶段在此基础扩展:文件读写、Shell 执行、Git 操作等。
|
|
1865
|
+
*
|
|
1866
|
+
* @param webConfig - Web 工具配置(可选),传入时启用 web_fetch 和 web_search
|
|
758
1867
|
*/
|
|
759
|
-
function createReplBuiltinTools() {
|
|
760
|
-
|
|
1868
|
+
function createReplBuiltinTools(webConfig) {
|
|
1869
|
+
const tools = [
|
|
761
1870
|
{
|
|
762
1871
|
id: "get_current_time",
|
|
763
1872
|
label: "获取当前时间",
|
|
@@ -831,6 +1940,11 @@ function createReplBuiltinTools() {
|
|
|
831
1940
|
}
|
|
832
1941
|
}
|
|
833
1942
|
];
|
|
1943
|
+
if (webConfig?.enabled !== false) {
|
|
1944
|
+
tools.push(createWebFetchTool(webConfig));
|
|
1945
|
+
tools.push(createWebSearchTool(webConfig));
|
|
1946
|
+
}
|
|
1947
|
+
return tools;
|
|
834
1948
|
}
|
|
835
1949
|
|
|
836
1950
|
//#endregion
|
|
@@ -939,8 +2053,6 @@ var ReplSession = class {
|
|
|
939
2053
|
tui;
|
|
940
2054
|
editor;
|
|
941
2055
|
outputArea;
|
|
942
|
-
header;
|
|
943
|
-
footer;
|
|
944
2056
|
options;
|
|
945
2057
|
_state = "idle";
|
|
946
2058
|
parser;
|
|
@@ -974,14 +2086,10 @@ var ReplSession = class {
|
|
|
974
2086
|
const theme = createTheme(this.options.color);
|
|
975
2087
|
const terminal = new ProcessTerminal();
|
|
976
2088
|
this.tui = new TUI(terminal);
|
|
977
|
-
this.header = new Text("", 1, 0);
|
|
978
2089
|
this.outputArea = new OutputArea();
|
|
979
|
-
this.footer = new Text("", 1, 0);
|
|
980
2090
|
this.editor = new ZapmycoEditor(this.tui, theme.editorTheme);
|
|
981
2091
|
const root = new Container();
|
|
982
|
-
root.addChild(this.header);
|
|
983
2092
|
root.addChild(this.outputArea);
|
|
984
|
-
root.addChild(this.footer);
|
|
985
2093
|
root.addChild(this.editor);
|
|
986
2094
|
this.tui.addChild(root);
|
|
987
2095
|
this.tui.setFocus(this.editor);
|
|
@@ -1006,10 +2114,7 @@ var ReplSession = class {
|
|
|
1006
2114
|
async start() {
|
|
1007
2115
|
this._state = "idle";
|
|
1008
2116
|
this.updateStatsState();
|
|
1009
|
-
|
|
1010
|
-
this.outputArea.append(welcomeLines);
|
|
1011
|
-
this.updateHeader();
|
|
1012
|
-
this.updateFooter();
|
|
2117
|
+
this.outputArea.append(["ZapMyco: 欢迎回来!", ""]);
|
|
1013
2118
|
this.tui.start();
|
|
1014
2119
|
}
|
|
1015
2120
|
/** 优雅关闭会话 */
|
|
@@ -1058,7 +2163,7 @@ var ReplSession = class {
|
|
|
1058
2163
|
try {
|
|
1059
2164
|
this._state = "executing";
|
|
1060
2165
|
this.updateStatsState();
|
|
1061
|
-
this.
|
|
2166
|
+
this.editor.setExecuting(true);
|
|
1062
2167
|
this.currentTaskAbort = new AbortController();
|
|
1063
2168
|
historyEntry = this.history.push({
|
|
1064
2169
|
timestamp: Date.now(),
|
|
@@ -1068,12 +2173,7 @@ var ReplSession = class {
|
|
|
1068
2173
|
goalId: `goal-${startTime}`,
|
|
1069
2174
|
rawInput
|
|
1070
2175
|
});
|
|
1071
|
-
const goalLines = [
|
|
1072
|
-
"",
|
|
1073
|
-
` 🎯 ${rawInput}`,
|
|
1074
|
-
"",
|
|
1075
|
-
" 💬 "
|
|
1076
|
-
];
|
|
2176
|
+
const goalLines = [`Me: ${rawInput}`, "ZapMyco: "];
|
|
1077
2177
|
this.outputArea.append(goalLines);
|
|
1078
2178
|
const outputHandler = (event) => {
|
|
1079
2179
|
if (event.taskId === taskId) {
|
|
@@ -1117,8 +2217,8 @@ var ReplSession = class {
|
|
|
1117
2217
|
error: taskResult.error,
|
|
1118
2218
|
status: taskResult.status
|
|
1119
2219
|
});
|
|
1120
|
-
}
|
|
1121
|
-
this.outputArea.append([""
|
|
2220
|
+
}
|
|
2221
|
+
this.outputArea.append([""]);
|
|
1122
2222
|
const duration = Date.now() - startTime;
|
|
1123
2223
|
const result = {
|
|
1124
2224
|
goalId: `goal-${startTime}`,
|
|
@@ -1183,7 +2283,7 @@ var ReplSession = class {
|
|
|
1183
2283
|
} finally {
|
|
1184
2284
|
this._state = "idle";
|
|
1185
2285
|
this.updateStatsState();
|
|
1186
|
-
this.
|
|
2286
|
+
this.editor.setExecuting(false);
|
|
1187
2287
|
this.currentTaskAbort = null;
|
|
1188
2288
|
this.currentTaskId = null;
|
|
1189
2289
|
}
|
|
@@ -1281,7 +2381,7 @@ var ReplSession = class {
|
|
|
1281
2381
|
* 注册 REPL 场景下的基础工具
|
|
1282
2382
|
*/
|
|
1283
2383
|
registerBuiltinTools() {
|
|
1284
|
-
this.agent.registerTools(createReplBuiltinTools());
|
|
2384
|
+
this.agent.registerTools(createReplBuiltinTools(this.config.web));
|
|
1285
2385
|
}
|
|
1286
2386
|
/** 设置编辑器事件绑定 */
|
|
1287
2387
|
setupEditorHandlers() {
|
|
@@ -1293,24 +2393,19 @@ var ReplSession = class {
|
|
|
1293
2393
|
this.cancelCurrentTask();
|
|
1294
2394
|
this.outputArea.append([
|
|
1295
2395
|
"",
|
|
1296
|
-
"
|
|
2396
|
+
"任务已取消",
|
|
1297
2397
|
""
|
|
1298
2398
|
]);
|
|
1299
2399
|
return;
|
|
1300
2400
|
}
|
|
1301
2401
|
ctrlCPressCount++;
|
|
1302
2402
|
if (ctrlCPressCount >= 2) {
|
|
1303
|
-
this.outputArea.append([
|
|
1304
|
-
"",
|
|
1305
|
-
" 再见!",
|
|
1306
|
-
""
|
|
1307
|
-
]);
|
|
1308
2403
|
this.shutdown("用户连续按下 Ctrl+C");
|
|
1309
2404
|
return;
|
|
1310
2405
|
}
|
|
1311
2406
|
this.outputArea.append([
|
|
1312
2407
|
"",
|
|
1313
|
-
"
|
|
2408
|
+
"(再次按下 Ctrl+C 可强制退出)",
|
|
1314
2409
|
""
|
|
1315
2410
|
]);
|
|
1316
2411
|
clearTimeout(ctrlCTimer);
|
|
@@ -1319,11 +2414,6 @@ var ReplSession = class {
|
|
|
1319
2414
|
}, 3e3);
|
|
1320
2415
|
};
|
|
1321
2416
|
this.editor.onCtrlD = () => {
|
|
1322
|
-
this.outputArea.append([
|
|
1323
|
-
"",
|
|
1324
|
-
" 再见!",
|
|
1325
|
-
""
|
|
1326
|
-
]);
|
|
1327
2417
|
this.shutdown("收到 EOF (Ctrl+D)");
|
|
1328
2418
|
};
|
|
1329
2419
|
}
|
|
@@ -1352,17 +2442,6 @@ var ReplSession = class {
|
|
|
1352
2442
|
updateStatsState() {
|
|
1353
2443
|
this.stats.state = this._state;
|
|
1354
2444
|
}
|
|
1355
|
-
/** 更新 header 文本 */
|
|
1356
|
-
updateHeader() {
|
|
1357
|
-
const theme = createTheme(this.options.color);
|
|
1358
|
-
this.header.setText(theme.heading(` zapmyco@${__VERSION__}`));
|
|
1359
|
-
}
|
|
1360
|
-
/** 更新 footer 文本 */
|
|
1361
|
-
updateFooter() {
|
|
1362
|
-
const theme = createTheme(this.options.color);
|
|
1363
|
-
const stateLabel = this._state === "idle" ? theme.success("空闲") : this._state === "executing" ? theme.warning("执行中") : theme.dim("关闭中");
|
|
1364
|
-
this.footer.setText(` ${stateLabel}`);
|
|
1365
|
-
}
|
|
1366
2445
|
};
|
|
1367
2446
|
|
|
1368
2447
|
//#endregion
|