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.
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { E as __VERSION__, T as APP_NAME, h as createLlmBasedAgent, o as logger, t as loadConfig, w as eventBus } from "../loader-BVZ993Oq.mjs";
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, Text, matchesKey, wrapTextWithAnsi } from "@mariozechner/pi-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(/&nbsp;/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
- return [
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
- const welcomeLines = this.renderer.renderWelcome(__VERSION__);
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.updateFooter();
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
- } else if (outputText) this.outputArea.appendText(outputText);
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.updateFooter();
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
- " (再次按下 Ctrl+C 可强制退出)",
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