zen-gitsync 2.18.23 → 2.18.24
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/package.json +5 -2
- package/src/cli/ai/tools.js +113 -0
- package/src/cli/ai/webSearch.js +260 -0
- package/src/config.js +71 -1
- package/src/ui/public/assets/{AgentView-DQOp7_6G.js → AgentView-CiWEJ_2v.js} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-BBVS3HeA.js → AppVersionBadge-D3IEpIR2.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-BwmMgaSS.js → BranchSelector-C0nB2LEE.js} +1 -1
- package/src/ui/public/assets/{ChatSilenceBadge-BnaLbCwp.js → ChatSilenceBadge-hb_fJMMg.js} +1 -1
- package/src/ui/public/assets/{CommitForm-BtRxJodG.js → CommitForm-CvOOlBZI.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-Qg_owAyQ.js → CommonDialog-C0zTwqdL.js} +1 -1
- package/src/ui/public/assets/{EditorView-DL3Lj4ho.js → EditorView-BWVtGf7z.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-BsxAtCqd.js → FlowExecutionViewer-ai4_NArk.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-lcqnsQE-.js → FlowOrchestrationWorkspace-Lhxo8V4t.js} +1 -1
- package/src/ui/public/assets/{LogList-CW3-fShz.js → LogList-CX8R6MyZ.js} +1 -1
- package/src/ui/public/assets/{MindmapView-ymN6XI7n.js → MindmapView-Cp2j7oyO.js} +1 -1
- package/src/ui/public/assets/{MonitorView-C4fFNPb2.js → MonitorView-CdjqaM-T.js} +1 -1
- package/src/ui/public/assets/{ProjectManagerView-ph_imDtI.js → ProjectManagerView-wlBbHwVQ.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-C2dqnR-6.js → ProjectStartupButton-DlZSyZx4.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-DFkRW2O4.js → RemoteManagerDialog-CcO2xc2Y.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-BBnMjldy.js → RemoteRepoCard-DB0iLLQa.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-CQcK1acs.js → SourceMapView-DpS3HjJy.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-XBkgNKCj.js → SvgIcon-Coxq3TGz.js} +1 -1
- package/src/ui/public/assets/{TaskExecutorPicker-B6p57RJY.js → TaskExecutorPicker-CJZD3YVi.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-CkXLP8y4.js → UserInputNode-C_znv4Pn.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-B0DU80Kl.js +6 -0
- package/src/ui/public/assets/{WorkbenchView-MUfM9DER.css → WorkbenchView-SonUoHZE.css} +1 -1
- package/src/ui/public/assets/{_plugin-vue_export-helper-B3dZvHQf.js → _plugin-vue_export-helper-PJOlbt_0.js} +4 -4
- package/src/ui/public/assets/configStore-ByUSd24J.js +1 -0
- package/src/ui/public/assets/index-BTEGKhWc.js +59 -0
- package/src/ui/public/assets/index-Q5dnmWKq.css +1 -0
- package/src/ui/public/assets/{relativeTime-Ce-gekf_.js → relativeTime-De2wWXk0.js} +1 -1
- package/src/ui/public/assets/{taskExecutionExport-Hfww5P9_.js → taskExecutionExport-Bb0HWZiH.js} +1 -1
- package/src/ui/public/assets/{toolsStore-oZEAoHnG.js → toolsStore-ChBM3BeQ.js} +1 -1
- package/src/ui/public/assets/{useWorkbenchProjects-BifctigH.js → useWorkbenchProjects-CrDCUDV3.js} +1 -1
- package/src/ui/public/index.html +7 -7
- package/src/ui/server/routes/config.js +14 -0
- package/src/ui/server/routes/workbench/dispatchInstruction.js +9 -0
- package/src/ui/server/routes/workbench/imageDescribe.js +183 -0
- package/src/ui/server/routes/workbench/imageDescribe.test.js +149 -0
- package/src/ui/server/routes/workbench/index.js +2 -1
- package/src/ui/server/routes/workbench/pdfText.js +22 -5
- package/src/ui/server/routes/workbench/taskRunner.js +30 -1
- package/src/ui/public/assets/WorkbenchView-cucUmAwl.js +0 -6
- package/src/ui/public/assets/configStore-HDlssep1.js +0 -1
- package/src/ui/public/assets/index-CAWXBaPa.js +0 -59
- package/src/ui/public/assets/index-CigPOqV6.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zen-gitsync",
|
|
3
|
-
"version": "2.18.
|
|
3
|
+
"version": "2.18.24",
|
|
4
4
|
"description": "Auto commit, scheduled sync, and visual GUI for Git. Run `g` in any repo for one-key commit & push, AI commit messages, scheduled background sync, and a drag-and-drop workflow builder.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -132,6 +132,8 @@
|
|
|
132
132
|
"verify:instance-close-hover": "node scripts/verify-instance-close-hover.cjs",
|
|
133
133
|
"verify:instance-close-hover:reverse": "node scripts/verify-instance-close-hover.cjs --reverse",
|
|
134
134
|
"verify:schedule": "node scripts/verify-schedule.cjs",
|
|
135
|
+
"verify:web-search": "node scripts/verify-web-search.mjs",
|
|
136
|
+
"verify:web-search:reverse": "node scripts/verify-web-search.mjs --reverse",
|
|
135
137
|
"e2e": "cd src/ui/client && npx playwright test",
|
|
136
138
|
"e2e:headed": "cd src/ui/client && npx playwright test --headed",
|
|
137
139
|
"e2e:install": "cd src/ui/client && npx playwright install chromium",
|
|
@@ -246,7 +248,8 @@
|
|
|
246
248
|
"pdf-parse": "^2.4.5",
|
|
247
249
|
"socket.io": "^4.8.1",
|
|
248
250
|
"socket.io-client": "^4.8.1",
|
|
249
|
-
"string-width": "^7.2.0"
|
|
251
|
+
"string-width": "^7.2.0",
|
|
252
|
+
"undici": "^7.30.1"
|
|
250
253
|
},
|
|
251
254
|
"devDependencies": {
|
|
252
255
|
"concurrently": "^10.0.3",
|
package/src/cli/ai/tools.js
CHANGED
|
@@ -44,6 +44,10 @@ import { checkImageFile, imageToDataUrl, formatBytes, isImagePath, SUPPORTED_IMA
|
|
|
44
44
|
// 本工具只负责登记/管理,真正的到点执行由 g ui 服务端的调度器完成。
|
|
45
45
|
import { createTask, loadTasks, deleteTask, updateTask } from '../../utils/scheduleStore.js'
|
|
46
46
|
import { nextFireAfter } from '../../utils/scheduleCron.js'
|
|
47
|
+
// 联网搜索 / 网页抓取:provider 抽象在 webSearch.js,配置(全局键 webSearch)走 configManager。
|
|
48
|
+
// 两者都读 config.json 顶层 —— CLI `g ai` 与 Web 智能体共用同一份,无需按端注入 ctx。
|
|
49
|
+
import configManager from '../../config.js'
|
|
50
|
+
import { runWebSearch, runWebFetch } from './webSearch.js'
|
|
47
51
|
|
|
48
52
|
// ──────────────────────────────────────────────
|
|
49
53
|
// 常量
|
|
@@ -106,6 +110,8 @@ const BINARY_EXTS = new Set([
|
|
|
106
110
|
// 用白名单而不是黑名单:漏加一个只读工具只是慢,错加一个写工具会改错文件。
|
|
107
111
|
export const PARALLEL_SAFE_TOOLS = new Set([
|
|
108
112
|
'read_file', 'read_image', 'list_files', 'search_text', 'list_projects',
|
|
113
|
+
// 只读的联网工具:彼此无先后依赖,可以和本地只读工具一起并发
|
|
114
|
+
'web_search', 'web_fetch',
|
|
109
115
|
])
|
|
110
116
|
|
|
111
117
|
/**
|
|
@@ -465,6 +471,49 @@ export const TOOL_DEFINITIONS = [
|
|
|
465
471
|
},
|
|
466
472
|
},
|
|
467
473
|
},
|
|
474
|
+
{
|
|
475
|
+
type: 'function',
|
|
476
|
+
function: {
|
|
477
|
+
name: 'web_search',
|
|
478
|
+
description: '联网搜索网页,返回每条结果的标题、URL 和摘要片段。'
|
|
479
|
+
+ '需要看某个结果的**完整正文**时,再对它的 URL 调用 web_fetch —— 这一步由你自己判断要不要做,别一次性抓一大堆。'
|
|
480
|
+
+ '适用于查最新资料、文档、版本号、公告等你不确定或可能已经过时的信息;纯本地文件/代码问题不要用它。',
|
|
481
|
+
parameters: {
|
|
482
|
+
type: 'object',
|
|
483
|
+
properties: {
|
|
484
|
+
query: { type: 'string', description: '搜索关键词,像人在搜索引擎里输入的那样。' },
|
|
485
|
+
max_results: { type: 'number', description: '返回条数,默认 5,最大 10。' },
|
|
486
|
+
provider: {
|
|
487
|
+
type: 'string',
|
|
488
|
+
enum: ['firecrawl', 'tavily', 'exa'],
|
|
489
|
+
description: '临时指定搜索引擎,不传就用设置里的默认(见 设置 → AI 模型配置 → 联网搜索)。',
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
required: ['query'],
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
type: 'function',
|
|
498
|
+
function: {
|
|
499
|
+
name: 'web_fetch',
|
|
500
|
+
description: '抓取一个网页的正文(转成 markdown)。用在 web_search 之后、需要深读某条结果时;也可直接抓用户给出的 URL。'
|
|
501
|
+
+ '正文很长时会被截断,优先抓真正相关的那几页,不要滥抓。',
|
|
502
|
+
parameters: {
|
|
503
|
+
type: 'object',
|
|
504
|
+
properties: {
|
|
505
|
+
url: { type: 'string', description: '要抓取的网页地址(http / https)。' },
|
|
506
|
+
max_chars: { type: 'number', description: '正文最多返回多少字符,默认 20000,最大 50000。' },
|
|
507
|
+
provider: {
|
|
508
|
+
type: 'string',
|
|
509
|
+
enum: ['firecrawl', 'tavily', 'exa'],
|
|
510
|
+
description: '临时指定抓取用的 provider,不传就用设置里的默认。',
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
required: ['url'],
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
},
|
|
468
517
|
]
|
|
469
518
|
|
|
470
519
|
// ──────────────────────────────────────────────
|
|
@@ -1397,6 +1446,68 @@ async function toolScheduleTask(args, ctx) {
|
|
|
1397
1446
|
}
|
|
1398
1447
|
}
|
|
1399
1448
|
|
|
1449
|
+
// 联网搜索 / 网页抓取。两个工具读同一份配置(config.json 的全局键 webSearch):
|
|
1450
|
+
// · web_search —— 回摘要(标题/URL/片段),模型据此判断要不要深读某条;
|
|
1451
|
+
// · web_fetch —— 抓某个 URL 的正文(markdown),供模型在 web_search 之后按需调用。
|
|
1452
|
+
// 配置读取走 configManager.loadConfig()(CLI 与 GUI 共享同一份 config.json);
|
|
1453
|
+
// 用户在设置里关掉后工具仍会注册,但返回一句"已关闭"的说明 —— 让模型知道这不是故障,
|
|
1454
|
+
// 也避免它反复重试。读取失败(配置损坏)时不阻断:按默认配置继续尝试。
|
|
1455
|
+
async function loadWebSearchSettings() {
|
|
1456
|
+
try {
|
|
1457
|
+
const cfg = await configManager.loadConfig()
|
|
1458
|
+
return cfg?.webSearch || null
|
|
1459
|
+
} catch {
|
|
1460
|
+
return null
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function formatWebSearchResults(query, provider, results) {
|
|
1465
|
+
if (!results.length) {
|
|
1466
|
+
return `联网搜索「${query}」没有返回结果(provider: ${provider})。换个说法或换关键词再试一次。`
|
|
1467
|
+
}
|
|
1468
|
+
const lines = results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet || '(无摘要)'}`)
|
|
1469
|
+
return `联网搜索「${query}」(provider: ${provider},共 ${results.length} 条):\n${lines.join('\n')}`
|
|
1470
|
+
+ '\n\n以上只是摘要;需要看某条的完整正文时,对它的 URL 调用 web_fetch。'
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
async function toolWebSearch(args) {
|
|
1474
|
+
const query = String(args.query || '').trim()
|
|
1475
|
+
if (!query) return '错误: web_search 需要非空的 query。'
|
|
1476
|
+
const settings = await loadWebSearchSettings()
|
|
1477
|
+
if (settings && settings.enabled === false) {
|
|
1478
|
+
return '联网搜索已在设置里关闭(设置 → AI 模型配置 → 联网搜索)。请基于已知信息回答;'
|
|
1479
|
+
+ '如果确实需要联网,提醒用户先在那里开启。'
|
|
1480
|
+
}
|
|
1481
|
+
try {
|
|
1482
|
+
const { provider, results } = await runWebSearch({
|
|
1483
|
+
query,
|
|
1484
|
+
maxResults: args.max_results,
|
|
1485
|
+
provider: args.provider,
|
|
1486
|
+
config: settings || undefined,
|
|
1487
|
+
})
|
|
1488
|
+
return formatWebSearchResults(query, provider, results)
|
|
1489
|
+
} catch (err) {
|
|
1490
|
+
return `错误: 联网搜索失败(${err?.message || String(err)})。可稍后重试;`
|
|
1491
|
+
+ '若一直失败,检查设置里的 provider / API Key / 代理是否配置正确。'
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
async function toolWebFetch(args) {
|
|
1496
|
+
const url = String(args.url || '').trim()
|
|
1497
|
+
if (!/^https?:\/\//i.test(url)) return '错误: web_fetch 需要以 http:// 或 https:// 开头的 url。'
|
|
1498
|
+
const settings = await loadWebSearchSettings()
|
|
1499
|
+
if (settings && settings.enabled === false) {
|
|
1500
|
+
return '联网搜索已在设置里关闭(设置 → AI 模型配置 → 联网搜索),无法抓取网页。'
|
|
1501
|
+
}
|
|
1502
|
+
try {
|
|
1503
|
+
const out = await runWebFetch({ url, provider: args.provider, config: settings || undefined, maxChars: args.max_chars })
|
|
1504
|
+
const head = `网页正文(provider: ${out.provider}):${out.title}\n${out.url}\n\n`
|
|
1505
|
+
return out.text ? head + out.text : head + '(没有取到正文 —— 可能是纯图片页、反爬拦截,或需要登录。)'
|
|
1506
|
+
} catch (err) {
|
|
1507
|
+
return `错误: 抓取网页失败(${err?.message || String(err)})。可换一个来源,或稍后重试。`
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1400
1511
|
// ──────────────────────────────────────────────
|
|
1401
1512
|
// 工具分发
|
|
1402
1513
|
// ──────────────────────────────────────────────
|
|
@@ -1415,6 +1526,8 @@ const TOOL_HANDLERS = {
|
|
|
1415
1526
|
ask_user: toolAskUser,
|
|
1416
1527
|
dispatch_task: toolDispatchTask,
|
|
1417
1528
|
schedule_task: toolScheduleTask,
|
|
1529
|
+
web_search: toolWebSearch,
|
|
1530
|
+
web_fetch: toolWebFetch,
|
|
1418
1531
|
}
|
|
1419
1532
|
|
|
1420
1533
|
/**
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// g ai 的联网搜索 / 网页抓取 —— provider 抽象层。
|
|
16
|
+
//
|
|
17
|
+
// 设计约定:
|
|
18
|
+
// · web_search **只回摘要**(标题 / URL / 摘要片段),不抓正文 —— 让模型自己判断
|
|
19
|
+
// 哪几条值得深读,再对那个 URL 调 web_fetch。token 花在"真的要看的那页"上。
|
|
20
|
+
// · provider 可切换(firecrawl / tavily / exa),配置来自 config.json 的全局键
|
|
21
|
+
// `webSearch`(形状与归一化见 src/config.js 的 normalizeWebSearch)。
|
|
22
|
+
// · 代理走 undici 的 ProxyAgent:Node 内置 fetch **不认** HTTPS_PROXY(除非进程
|
|
23
|
+
// 启动时带 --use-env-proxy),所以配了代理就用 undici 自己的 fetch + ProxyAgent,
|
|
24
|
+
// 两者同源、版本自洽。undici 是可选依赖:没配代理时零影响。
|
|
25
|
+
// · CA 补齐(enableSystemCAs)与远程 MCP 同一手法:Node 只认内置根证书,
|
|
26
|
+
// 企业/自签代理环境下会 UNABLE_TO_VERIFY_LEAF_SIGNATURE。
|
|
27
|
+
//
|
|
28
|
+
// 与 tools.js 的边界:本文件只负责"发请求 + 把各家返回裁成统一形状";
|
|
29
|
+
// 工具 schema、结果格式化、给模型的错误话术都在 tools.js 的 handler 里。
|
|
30
|
+
|
|
31
|
+
import { enableSystemCAs } from './systemCa.js'
|
|
32
|
+
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 20000
|
|
34
|
+
const DEFAULT_MAX_RESULTS = 5
|
|
35
|
+
const MAX_RESULTS_LIMIT = 10
|
|
36
|
+
const MAX_SNIPPET_CHARS = 500
|
|
37
|
+
const DEFAULT_FETCH_CHARS = 20000
|
|
38
|
+
const MAX_FETCH_CHARS = 50000
|
|
39
|
+
|
|
40
|
+
// provider 白名单 —— 与 src/config.js 的 WEB_SEARCH_PROVIDERS 必须一致。
|
|
41
|
+
export const WEB_SEARCH_PROVIDERS = ['firecrawl', 'tavily', 'exa']
|
|
42
|
+
|
|
43
|
+
function str(v) {
|
|
44
|
+
return typeof v === 'string' ? v : (v === undefined || v === null ? '' : String(v))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function clip(text, max = MAX_SNIPPET_CHARS) {
|
|
48
|
+
const s = str(text).replace(/\s+/g, ' ').trim()
|
|
49
|
+
return s.length > max ? `${s.slice(0, max)}…` : s
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clampResults(n) {
|
|
53
|
+
const num = Number(n)
|
|
54
|
+
const base = Number.isFinite(num) && num > 0 ? Math.floor(num) : DEFAULT_MAX_RESULTS
|
|
55
|
+
return Math.max(1, Math.min(MAX_RESULTS_LIMIT, base))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// CA 补齐(幂等、只做一次)。Node < 24 或异常静默跳过,不影响主流程。
|
|
59
|
+
let caReady = false
|
|
60
|
+
function ensureCa() {
|
|
61
|
+
if (caReady) return
|
|
62
|
+
caReady = true
|
|
63
|
+
try { enableSystemCAs() } catch { /* 低版本 Node 没有该 API,忽略 */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// undici 是**可选**依赖:只在配了代理时才需要,动态 import(没装也不影响直连)。
|
|
67
|
+
let undiciModule = null
|
|
68
|
+
let undiciTried = false
|
|
69
|
+
async function loadUndici() {
|
|
70
|
+
if (undiciTried) return undiciModule
|
|
71
|
+
undiciTried = true
|
|
72
|
+
try {
|
|
73
|
+
undiciModule = await import('undici')
|
|
74
|
+
} catch {
|
|
75
|
+
undiciModule = null
|
|
76
|
+
}
|
|
77
|
+
return undiciModule
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 构造一个 http 函数:(url, init) => Promise<解析后的 JSON>。
|
|
82
|
+
* 直连走全局 fetch;配了代理则换用 undici 的 fetch + ProxyAgent(同源自洽)。
|
|
83
|
+
* 每次调用都新建 AbortController 超时,异常一律翻译成人话再抛。
|
|
84
|
+
*/
|
|
85
|
+
async function makeHttp(proxy) {
|
|
86
|
+
const proxyUrl = str(proxy).trim()
|
|
87
|
+
let fetchFn = fetch
|
|
88
|
+
let dispatcher
|
|
89
|
+
if (proxyUrl) {
|
|
90
|
+
const undici = await loadUndici()
|
|
91
|
+
if (!undici || typeof undici.fetch !== 'function' || typeof undici.ProxyAgent !== 'function') {
|
|
92
|
+
throw new Error(`已配置代理 ${proxyUrl},但缺少 undici 依赖,无法走代理。请安装 undici,或清空设置里的代理地址改用直连。`)
|
|
93
|
+
}
|
|
94
|
+
fetchFn = undici.fetch
|
|
95
|
+
try {
|
|
96
|
+
dispatcher = new undici.ProxyAgent(proxyUrl)
|
|
97
|
+
} catch (err) {
|
|
98
|
+
throw new Error(`代理地址无法解析:${proxyUrl}(${err.message})`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return async function http(url, { method = 'GET', headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
103
|
+
ensureCa()
|
|
104
|
+
const controller = new AbortController()
|
|
105
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
106
|
+
let resp
|
|
107
|
+
try {
|
|
108
|
+
resp = await fetchFn(url, {
|
|
109
|
+
method,
|
|
110
|
+
headers,
|
|
111
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
112
|
+
signal: controller.signal,
|
|
113
|
+
...(dispatcher ? { dispatcher } : {}),
|
|
114
|
+
})
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (err?.name === 'AbortError') throw new Error(`请求超时(${timeoutMs / 1000}s):${url}`)
|
|
117
|
+
throw new Error(`请求失败:${err.message}`)
|
|
118
|
+
} finally {
|
|
119
|
+
clearTimeout(timer)
|
|
120
|
+
}
|
|
121
|
+
const text = await resp.text()
|
|
122
|
+
if (!resp.ok) {
|
|
123
|
+
throw new Error(`HTTP ${resp.status}${text ? ` ${clip(text, 300)}` : ''}`)
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(text)
|
|
127
|
+
} catch {
|
|
128
|
+
throw new Error(`返回不是合法 JSON:${clip(text, 200)}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeResults(list) {
|
|
134
|
+
const out = []
|
|
135
|
+
for (const it of Array.isArray(list) ? list : []) {
|
|
136
|
+
if (!it || typeof it !== 'object') continue
|
|
137
|
+
const url = str(it.url || it.link).trim()
|
|
138
|
+
if (!/^https?:\/\//i.test(url)) continue
|
|
139
|
+
out.push({
|
|
140
|
+
title: clip(str(it.title), 200) || url,
|
|
141
|
+
url,
|
|
142
|
+
snippet: clip(str(it.description ?? it.snippet ?? it.content ?? it.text ?? it.summary)),
|
|
143
|
+
})
|
|
144
|
+
if (out.length >= MAX_RESULTS_LIMIT) break
|
|
145
|
+
}
|
|
146
|
+
return out
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 各 provider 的 search / fetch 实现。http 是上面 makeHttp 造的函数。
|
|
150
|
+
const PROVIDERS = {
|
|
151
|
+
firecrawl: {
|
|
152
|
+
// 无 key 也能用(按 IP 限流);有 key 时带上 Bearer。
|
|
153
|
+
authHeaders: (apiKey) => (apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
154
|
+
async search({ query, maxResults, apiKey, http }) {
|
|
155
|
+
const data = await http('https://api.firecrawl.dev/v2/search', {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
headers: { 'Content-Type': 'application/json', ...PROVIDERS.firecrawl.authHeaders(apiKey) },
|
|
158
|
+
body: { query, limit: maxResults },
|
|
159
|
+
})
|
|
160
|
+
const list = Array.isArray(data?.data) ? data.data
|
|
161
|
+
: Array.isArray(data?.data?.web) ? data.data.web
|
|
162
|
+
: Array.isArray(data?.web) ? data.web : []
|
|
163
|
+
return normalizeResults(list)
|
|
164
|
+
},
|
|
165
|
+
async fetch({ url, apiKey, http }) {
|
|
166
|
+
const data = await http('https://api.firecrawl.dev/v2/scrape', {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: { 'Content-Type': 'application/json', ...PROVIDERS.firecrawl.authHeaders(apiKey) },
|
|
169
|
+
body: { url, formats: ['markdown'] },
|
|
170
|
+
})
|
|
171
|
+
const md = data?.data?.markdown ?? data?.markdown ?? ''
|
|
172
|
+
return { title: clip(str(data?.data?.metadata?.title), 200), url, text: str(md) }
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
tavily: {
|
|
176
|
+
async search({ query, maxResults, apiKey, http }) {
|
|
177
|
+
if (!apiKey) throw new Error('未配置 Tavily API Key(设置 → AI 模型配置 → 联网搜索)')
|
|
178
|
+
const data = await http('https://api.tavily.com/search', {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: { 'Content-Type': 'application/json' },
|
|
181
|
+
body: { api_key: apiKey, query, max_results: maxResults, search_depth: 'basic' },
|
|
182
|
+
})
|
|
183
|
+
return normalizeResults(data?.results)
|
|
184
|
+
},
|
|
185
|
+
async fetch({ url, apiKey, http }) {
|
|
186
|
+
if (!apiKey) throw new Error('未配置 Tavily API Key(设置 → AI 模型配置 → 联网搜索)')
|
|
187
|
+
const data = await http('https://api.tavily.com/extract', {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: { 'Content-Type': 'application/json' },
|
|
190
|
+
body: { api_key: apiKey, urls: [url] },
|
|
191
|
+
})
|
|
192
|
+
const item = Array.isArray(data?.results) ? data.results[0] : null
|
|
193
|
+
return { title: clip(str(item?.title), 200), url, text: str(item?.raw_content) }
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
exa: {
|
|
197
|
+
async search({ query, maxResults, apiKey, http }) {
|
|
198
|
+
if (!apiKey) throw new Error('未配置 Exa API Key(设置 → AI 模型配置 → 联网搜索)')
|
|
199
|
+
const data = await http('https://api.exa.ai/search', {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
|
|
202
|
+
body: { query, numResults: maxResults, contents: { text: { maxCharacters: MAX_SNIPPET_CHARS } } },
|
|
203
|
+
})
|
|
204
|
+
return normalizeResults(data?.results)
|
|
205
|
+
},
|
|
206
|
+
async fetch({ url, apiKey, http }) {
|
|
207
|
+
if (!apiKey) throw new Error('未配置 Exa API Key(设置 → AI 模型配置 → 联网搜索)')
|
|
208
|
+
const data = await http('https://api.exa.ai/contents', {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
|
|
211
|
+
body: { urls: [url], text: true },
|
|
212
|
+
})
|
|
213
|
+
const item = Array.isArray(data?.results) ? data.results[0] : null
|
|
214
|
+
return { title: clip(str(item?.title), 200), url, text: str(item?.text) }
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 选 provider:显式传入的(工具参数)优先,其次配置里的,最后兜底 firecrawl。
|
|
221
|
+
* 不在白名单里的值一律忽略 —— 免得模型瞎传一个不存在的名字把请求打到未知端点。
|
|
222
|
+
*/
|
|
223
|
+
function pickProvider(explicit, configProvider) {
|
|
224
|
+
const a = str(explicit).trim().toLowerCase()
|
|
225
|
+
if (WEB_SEARCH_PROVIDERS.includes(a)) return a
|
|
226
|
+
const b = str(configProvider).trim().toLowerCase()
|
|
227
|
+
if (WEB_SEARCH_PROVIDERS.includes(b)) return b
|
|
228
|
+
return 'firecrawl'
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 执行一次联网搜索,返回 { provider, results }。
|
|
233
|
+
* @param {{ query: string, maxResults?: number, provider?: string,
|
|
234
|
+
* config?: { provider?: string, apiKeys?: Record<string,string>, proxy?: string } }} opts
|
|
235
|
+
*/
|
|
236
|
+
export async function runWebSearch({ query, maxResults, provider, config } = {}) {
|
|
237
|
+
const cfg = config || {}
|
|
238
|
+
const chosen = pickProvider(provider, cfg.provider)
|
|
239
|
+
const apiKeys = (cfg.apiKeys && typeof cfg.apiKeys === 'object') ? cfg.apiKeys : {}
|
|
240
|
+
const http = await makeHttp(cfg.proxy)
|
|
241
|
+
const limit = clampResults(maxResults)
|
|
242
|
+
const results = await PROVIDERS[chosen].search({ query: str(query).trim(), maxResults: limit, apiKey: str(apiKeys[chosen]), http })
|
|
243
|
+
return { provider: chosen, results }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 抓取一个网页的正文(markdown)。返回 { provider, title, url, text }。
|
|
248
|
+
* 正文可能很长,进上下文前先按 maxChars 裁(默认 20,000 字符)。
|
|
249
|
+
*/
|
|
250
|
+
export async function runWebFetch({ url, provider, config, maxChars } = {}) {
|
|
251
|
+
const cfg = config || {}
|
|
252
|
+
const chosen = pickProvider(provider, cfg.provider)
|
|
253
|
+
const apiKeys = (cfg.apiKeys && typeof cfg.apiKeys === 'object') ? cfg.apiKeys : {}
|
|
254
|
+
const http = await makeHttp(cfg.proxy)
|
|
255
|
+
const out = await PROVIDERS[chosen].fetch({ url: str(url).trim(), apiKey: str(apiKeys[chosen]), http })
|
|
256
|
+
const limit = Math.max(1000, Math.min(MAX_FETCH_CHARS, Number(maxChars) || DEFAULT_FETCH_CHARS))
|
|
257
|
+
return { provider: chosen, title: out.title || str(url), url: out.url || str(url), text: str(out.text).slice(0, limit) }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export default { WEB_SEARCH_PROVIDERS, runWebSearch, runWebFetch }
|
package/src/config.js
CHANGED
|
@@ -239,6 +239,53 @@ export function normalizeAgentDefaultPrompt(value) {
|
|
|
239
239
|
return value.slice(0, AGENT_DEFAULT_PROMPT_MAX);
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// ──────────────────────────────────────────────
|
|
243
|
+
// 联网搜索(web_search / web_fetch 工具,全局配置,跨项目共享)
|
|
244
|
+
// ──────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 联网搜索可选的 provider 白名单。**这里是权威**;前端
|
|
248
|
+
* `utils/webSearchProviders.ts` 里另有一份(只做展示 & 申请入口)。
|
|
249
|
+
* · firecrawl —— 无 key 也能用(按 IP 限流),注册免费 key 后每月 1,000 credits
|
|
250
|
+
* · tavily —— 必须有 key(免费 1,000 credits/月)
|
|
251
|
+
* · exa —— 必须有 key(免费 $10 credits/月)
|
|
252
|
+
*/
|
|
253
|
+
export const WEB_SEARCH_PROVIDERS = ['firecrawl', 'tavily', 'exa'];
|
|
254
|
+
const WEB_SEARCH_DEFAULT_PROVIDER = 'firecrawl';
|
|
255
|
+
const WEB_SEARCH_API_KEY_MAX = 300;
|
|
256
|
+
const WEB_SEARCH_PROXY_MAX = 300;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 规范化「联网搜索」配置(全局配置,跨项目共享)。
|
|
260
|
+
*
|
|
261
|
+
* 形状:{ enabled, provider, apiKeys: {[provider]: key}, proxy }
|
|
262
|
+
*
|
|
263
|
+
* 语义约定:
|
|
264
|
+
* · undefined / null → null(没配过,调用方回落 defaultConfig = 默认开启 + firecrawl)
|
|
265
|
+
* · 非普通对象(含数组)→ null(手改坏了,整组回落默认,与 normalizeAgent* 同一口径)
|
|
266
|
+
* · **单个字段坏只回落该字段**,不整组拒绝 —— 一个 key 写错不该把"开关/代理"也重置
|
|
267
|
+
* · apiKeys 只保留白名单 provider 的键;非字符串值 → 该键置空串
|
|
268
|
+
* · proxy 空串是合法值(= 直连),照写;非字符串 → 空串
|
|
269
|
+
*/
|
|
270
|
+
export function normalizeWebSearch(value) {
|
|
271
|
+
if (value === undefined || value === null) return null;
|
|
272
|
+
if (typeof value !== 'object' || Array.isArray(value)) return null;
|
|
273
|
+
const rawKeys = (value.apiKeys && typeof value.apiKeys === 'object' && !Array.isArray(value.apiKeys))
|
|
274
|
+
? value.apiKeys : {};
|
|
275
|
+
const apiKeys = {};
|
|
276
|
+
for (const p of WEB_SEARCH_PROVIDERS) {
|
|
277
|
+
const v = rawKeys[p];
|
|
278
|
+
apiKeys[p] = typeof v === 'string' ? v.trim().slice(0, WEB_SEARCH_API_KEY_MAX) : '';
|
|
279
|
+
}
|
|
280
|
+
const providerRaw = typeof value.provider === 'string' ? value.provider.trim().toLowerCase() : '';
|
|
281
|
+
return {
|
|
282
|
+
enabled: typeof value.enabled === 'boolean' ? value.enabled : true,
|
|
283
|
+
provider: WEB_SEARCH_PROVIDERS.includes(providerRaw) ? providerRaw : WEB_SEARCH_DEFAULT_PROVIDER,
|
|
284
|
+
apiKeys,
|
|
285
|
+
proxy: typeof value.proxy === 'string' ? value.proxy.trim().slice(0, WEB_SEARCH_PROXY_MAX) : '',
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
242
289
|
/**
|
|
243
290
|
* 三个提示通道(页面提示 / 浏览器通知 / 提示音)的最终取值。
|
|
244
291
|
*
|
|
@@ -340,6 +387,17 @@ const defaultConfig = {
|
|
|
340
387
|
// 见 workbench/chatPrompts.js)。空串 = 不注入。
|
|
341
388
|
// 归一化见 normalizeAgentDefaultPrompt。
|
|
342
389
|
agentDefaultPrompt: '',
|
|
390
|
+
// 联网搜索(web_search / web_fetch 工具)。全局配置,CLI `g ai` 与 Web 智能体共用。
|
|
391
|
+
// 默认开启:模型能联网查资料,默认走 firecrawl 的**无 key**接口(按 IP 限流);
|
|
392
|
+
// 各 provider 的 key 留空即用各自的"免 key / 降级"形态(tavily / exa 没 key 会直接失败)。
|
|
393
|
+
// 代理留空 = 直连;填了就走 undici 的 ProxyAgent(如 http://127.0.0.1:7890)。
|
|
394
|
+
// 归一化见 normalizeWebSearch。
|
|
395
|
+
webSearch: {
|
|
396
|
+
enabled: true,
|
|
397
|
+
provider: 'firecrawl',
|
|
398
|
+
apiKeys: { firecrawl: '', tavily: '', exa: '' },
|
|
399
|
+
proxy: '',
|
|
400
|
+
},
|
|
343
401
|
// 工作台任务执行器(claude | opencode | codex | codebuddy)。全局配置,跨项目共享。
|
|
344
402
|
// 决定「执行任务 / 执行子任务 / 从此处开始 / 简单任务续聊」这条链路
|
|
345
403
|
// 默认 spawn 哪个本地 CLI;执行入口可以按次覆盖(见 workbench 执行路由)。
|
|
@@ -692,6 +750,7 @@ async function loadConfig() {
|
|
|
692
750
|
?? defaultConfig.agentPresetPromptsByView,
|
|
693
751
|
agentDefaultPrompt: normalizeAgentDefaultPrompt(raw.agentDefaultPrompt)
|
|
694
752
|
?? defaultConfig.agentDefaultPrompt,
|
|
753
|
+
webSearch: normalizeWebSearch(raw.webSearch) ?? defaultConfig.webSearch,
|
|
695
754
|
...resolveNotifySwitches(raw)
|
|
696
755
|
};
|
|
697
756
|
}
|
|
@@ -721,6 +780,8 @@ async function loadConfig() {
|
|
|
721
780
|
// 同 agentPresetPrompts:全局配置,始终取顶层,防止被项目配置里的旧值覆盖
|
|
722
781
|
agentDefaultPrompt: normalizeAgentDefaultPrompt(raw?.agentDefaultPrompt)
|
|
723
782
|
?? defaultConfig.agentDefaultPrompt,
|
|
783
|
+
// 同 agentDefaultPrompt:全局配置,始终取顶层,防止被项目配置里的旧值覆盖
|
|
784
|
+
webSearch: normalizeWebSearch(raw?.webSearch) ?? defaultConfig.webSearch,
|
|
724
785
|
...resolveNotifySwitches(raw)
|
|
725
786
|
};
|
|
726
787
|
}
|
|
@@ -774,7 +835,7 @@ async function saveConfig(config) {
|
|
|
774
835
|
// 项目级是这一族键最容易踩的坑。解构出来本身不写回顶层(它已不再被任何地方读取)。
|
|
775
836
|
const {
|
|
776
837
|
theme, locale, models, ui, aiMaxToolIterations, aiMaxRequestTokens, taskExecutor,
|
|
777
|
-
agentPresetPrompts, agentPresetPromptsByView, agentDefaultPrompt,
|
|
838
|
+
agentPresetPrompts, agentPresetPromptsByView, agentDefaultPrompt, webSearch,
|
|
778
839
|
notifyOnTaskDone: legacyNotifyOnTaskDone,
|
|
779
840
|
notifyPageOnTaskDone, notifyBrowserOnTaskDone, notifySoundOnTaskDone,
|
|
780
841
|
...projectConfig
|
|
@@ -840,6 +901,12 @@ async function saveConfig(config) {
|
|
|
840
901
|
if (normalizedAgentPrompt !== null) {
|
|
841
902
|
raw.agentDefaultPrompt = normalizedAgentPrompt;
|
|
842
903
|
}
|
|
904
|
+
// 联网搜索同属全局设置:结构坏(非普通对象)不落盘,保留磁盘旧值;
|
|
905
|
+
// 全默认 / 空 key 是**合法值**,照写 —— 与上面各键同一套语义。
|
|
906
|
+
const normalizedWebSearch = normalizeWebSearch(webSearch);
|
|
907
|
+
if (normalizedWebSearch !== null) {
|
|
908
|
+
raw.webSearch = normalizedWebSearch;
|
|
909
|
+
}
|
|
843
910
|
|
|
844
911
|
// 写入当前项目配置(在 defaultConfig 基础上合并,但不清空顶层其它键)
|
|
845
912
|
const existingProjectConfig = (raw.projects[key] && typeof raw.projects[key] === 'object') ? raw.projects[key] : {};
|
|
@@ -1052,6 +1119,9 @@ export default {
|
|
|
1052
1119
|
normalizeAgentPresetPromptsByView,
|
|
1053
1120
|
// g ai 智能体默认提示词的规范化(同上,见 /api/config/save-ai-settings)
|
|
1054
1121
|
normalizeAgentDefaultPrompt,
|
|
1122
|
+
// 联网搜索的规范化/白名单(同上,见 /api/config/save-ai-settings;CLI 工具层也要用)
|
|
1123
|
+
normalizeWebSearch,
|
|
1124
|
+
WEB_SEARCH_PROVIDERS,
|
|
1055
1125
|
};
|
|
1056
1126
|
|
|
1057
1127
|
// 命名导出 — 用于测试与外部复用
|