dsh-tool-switch 0.0.0-stage → 0.2.1
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/INSTALL.md +321 -0
- package/LICENSE +21 -0
- package/README.md +171 -2
- package/cordis.patch.yml +9 -0
- package/icon.svg +18 -0
- package/index.js +719 -0
- package/lib/client.js +759 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +53 -4
package/index.js
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
// dsh-tool-switch — 会话级工具可见性开关(Host 半侧)
|
|
2
|
+
//
|
|
3
|
+
// 目标:让用户在某个会话里自己决定「这一轮对话中,模型能用哪些工具」。
|
|
4
|
+
//
|
|
5
|
+
// 机制:
|
|
6
|
+
// 1. `ctx.tools.restrict({ allow })` 是 DSH 已有的按 agent 遮罩 API:只有
|
|
7
|
+
// 在 agent 作用域上下文(agent.ctx)里调用才会只影响该 agent。遮罩会从
|
|
8
|
+
// 模型可见的 schema 集合、PTC 绑定与执行集合里同时移除工具,因此隐藏的
|
|
9
|
+
// 工具既不进提示词,也无法被调用(返回 UNKNOWN_TOOL)。
|
|
10
|
+
// 2. 本插件按 sessionId 记住「被关闭的工具名」,在 agent 创建时、以及工具
|
|
11
|
+
// 注册表变化(MCP 服务器晚到、插件热插拔)时重新计算 allow 掩码并重装。
|
|
12
|
+
// 3. 浏览器半侧(lib/client.js)通过本文件注册的 HTTP 路由读取/写入选择;
|
|
13
|
+
// 路由挂在框架自带的 webServer 上,页面与它是同源(桌面外壳会带 cookie
|
|
14
|
+
// 转发),因此不需要额外的鉴权通道。
|
|
15
|
+
//
|
|
16
|
+
// 语义说明:只记「关闭」集合而不是「开启」集合,这样后到的工具默认可用,
|
|
17
|
+
// 新增 MCP 工具不会被悄悄藏起来。
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import os from 'node:os'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
|
|
23
|
+
export const name = 'dsh-tool-switch'
|
|
24
|
+
|
|
25
|
+
/** Cordis:这两个服务不存在时插件不激活。 */
|
|
26
|
+
export const inject = ['tools', 'agents']
|
|
27
|
+
|
|
28
|
+
/** 浏览器半侧使用的路由(同源,精确匹配)。 */
|
|
29
|
+
export const ROUTE = '/tool-switch/state'
|
|
30
|
+
|
|
31
|
+
/** 请求体上限,避免误传大文件。 */
|
|
32
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
33
|
+
|
|
34
|
+
/** 工具名长度上限(防止脏状态文件把 restrict 撑爆)。 */
|
|
35
|
+
const MAX_NAME_LENGTH = 200
|
|
36
|
+
|
|
37
|
+
/** 模型可调用的开关工具名;它自己永远不会被关掉,否则唯一的 headless 入口就没了。 */
|
|
38
|
+
const SWITCH_TOOL_NAME = 'tool_switch'
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// 分组规则:先按内置能力分类,MCP 工具按服务器单独成组,认不出来的再按
|
|
42
|
+
// 名字前缀兜底(community 插件通常用 <产物>_<动作> 命名,例如 x_post、
|
|
43
|
+
// comfyui_run),这样 300+ 个工具也不会挤进一个「其他」。
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const CORE_GROUPS = [
|
|
47
|
+
{
|
|
48
|
+
id: 'session',
|
|
49
|
+
label: '会话与计划',
|
|
50
|
+
order: 0,
|
|
51
|
+
test: (name) =>
|
|
52
|
+
/^(todo_write|create_goal|get_goal|update_goal|ask_user_question|present|skill|exit_plan_mode)$/.test(name) ||
|
|
53
|
+
/^schedule_/.test(name),
|
|
54
|
+
},
|
|
55
|
+
{ id: 'shell', label: '终端与执行', order: 1, test: (name) => /^(pwsh|bash|run_code|shell|terminal|exec)$/.test(name) || /^job_/.test(name) },
|
|
56
|
+
{
|
|
57
|
+
id: 'fs',
|
|
58
|
+
label: '文件与检索',
|
|
59
|
+
order: 2,
|
|
60
|
+
test: (name) =>
|
|
61
|
+
/^(read|write|edit|glob|grep|read_image|read_animation)$/.test(name) ||
|
|
62
|
+
/^(ls|list_dir|list_files|move|copy|remove|mkdir)/.test(name) ||
|
|
63
|
+
name === 'load_workspace_dependencies',
|
|
64
|
+
},
|
|
65
|
+
{ id: 'web', label: '网络检索', order: 3, test: (name) => /^(web_search|web_fetch)$/.test(name) },
|
|
66
|
+
{
|
|
67
|
+
id: 'agent',
|
|
68
|
+
label: '子代理与编排',
|
|
69
|
+
order: 4,
|
|
70
|
+
test: (name) =>
|
|
71
|
+
/^(subagent|subagent_fork|send_message|list_agents|interrupt_agent|workflow)$/.test(name) || /^list_subagent_/.test(name),
|
|
72
|
+
},
|
|
73
|
+
{ id: 'office', label: '文档与演示', order: 5, test: (name) => /^(office_|ppt)/.test(name) },
|
|
74
|
+
{ id: 'media', label: '图像与音视频', order: 6, test: (name) => /^(comfyui_|image_generate)/.test(name) },
|
|
75
|
+
{
|
|
76
|
+
id: 'mcp-resource',
|
|
77
|
+
label: 'MCP 资源',
|
|
78
|
+
order: 7,
|
|
79
|
+
test: (name) => /^(list_mcp_resource|list_mcp_resources|list_mcp_resource_templates|read_mcp_resource)$/.test(name),
|
|
80
|
+
},
|
|
81
|
+
{ id: 'social', label: '社交与发布', order: 8, test: (name) => /^x_(post|delete|search|timeline|thread|status|probe)$/.test(name) },
|
|
82
|
+
{
|
|
83
|
+
id: 'toolchain',
|
|
84
|
+
label: '工具链与维护',
|
|
85
|
+
order: 9,
|
|
86
|
+
test: (name) => /^(purge_|install_local_workbench|plugin_manager|working_directory|gain_qq_tools|ops_)/.test(name),
|
|
87
|
+
},
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
const MCP_PATTERN = /^mcp__([A-Za-z0-9_.-]+)__/
|
|
91
|
+
|
|
92
|
+
/** 把工具名归到一个分组。 */
|
|
93
|
+
function groupOf(name) {
|
|
94
|
+
const mcp = MCP_PATTERN.exec(name)
|
|
95
|
+
if (mcp !== null) {
|
|
96
|
+
const server = mcp[1]
|
|
97
|
+
return { id: `mcp:${server}`, label: `${server} · MCP`, order: 100, tool: name.slice(mcp[0].length) }
|
|
98
|
+
}
|
|
99
|
+
for (const group of CORE_GROUPS) {
|
|
100
|
+
if (group.test(name)) return { id: group.id, label: group.label, order: group.order, tool: name }
|
|
101
|
+
}
|
|
102
|
+
const underscore = name.indexOf('_')
|
|
103
|
+
if (underscore > 0) {
|
|
104
|
+
const prefix = name.slice(0, underscore)
|
|
105
|
+
return { id: `x:${prefix}`, label: prefix, order: 800, tool: name }
|
|
106
|
+
}
|
|
107
|
+
return { id: 'other', label: '其他', order: 900, tool: name }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// 选择持久化:~/.dsh-tool-switch/state.json
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
function readJson(file, fallback) {
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
117
|
+
} catch {
|
|
118
|
+
return fallback
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sessionIdOf(agent) {
|
|
123
|
+
const sessionId = agent?.session?.id ?? agent?.id
|
|
124
|
+
return typeof sessionId === 'string' ? sessionId : undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class SelectionStore {
|
|
128
|
+
constructor(file) {
|
|
129
|
+
this.file = file
|
|
130
|
+
const loaded = readJson(file, undefined)
|
|
131
|
+
this.data =
|
|
132
|
+
loaded !== null && typeof loaded === 'object' && typeof loaded.sessions === 'object' && loaded.sessions !== null
|
|
133
|
+
? loaded
|
|
134
|
+
: { version: 1, sessions: {} }
|
|
135
|
+
if (this.data.version !== 1) this.data.version = 1
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 该会话被关闭的工具名集合。 */
|
|
139
|
+
disabled(sessionId) {
|
|
140
|
+
const entry = this.data.sessions[sessionId]
|
|
141
|
+
const list = Array.isArray(entry?.disabled) ? entry.disabled : []
|
|
142
|
+
const result = new Set()
|
|
143
|
+
for (const name of list) {
|
|
144
|
+
if (typeof name !== 'string') continue
|
|
145
|
+
if (name.length === 0 || name.length > MAX_NAME_LENGTH) continue
|
|
146
|
+
result.add(name)
|
|
147
|
+
}
|
|
148
|
+
return result
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 覆盖该会话的关闭集合;空集合等于「全部启用」。 */
|
|
152
|
+
set(sessionId, disabled) {
|
|
153
|
+
const cleaned = new Set()
|
|
154
|
+
for (const name of disabled) {
|
|
155
|
+
if (typeof name !== 'string') continue
|
|
156
|
+
if (name.length === 0 || name.length > MAX_NAME_LENGTH) continue
|
|
157
|
+
cleaned.add(name)
|
|
158
|
+
}
|
|
159
|
+
const list = [...cleaned].sort()
|
|
160
|
+
if (list.length === 0) delete this.data.sessions[sessionId]
|
|
161
|
+
else this.data.sessions[sessionId] = { disabled: list, updatedAt: new Date().toISOString() }
|
|
162
|
+
this.flush()
|
|
163
|
+
return cleaned
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
flush() {
|
|
167
|
+
try {
|
|
168
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true })
|
|
169
|
+
fs.writeFileSync(this.file, `${JSON.stringify(this.data, null, 2)}\n`, 'utf8')
|
|
170
|
+
} catch {
|
|
171
|
+
/* 持久化失败不影响本次运行内的效果 */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// 插件入口
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
export function apply(ctx, config) {
|
|
181
|
+
const settings = config ?? {}
|
|
182
|
+
const stateDir = settings.stateDir ? String(settings.stateDir) : path.join(os.homedir(), '.dsh-tool-switch')
|
|
183
|
+
const store = new SelectionStore(path.join(stateDir, 'state.json'))
|
|
184
|
+
|
|
185
|
+
const warn = (message, cause) => {
|
|
186
|
+
try {
|
|
187
|
+
ctx.logger?.warn?.(`[tool-switch] ${message}${cause === undefined ? '' : `: ${String(cause?.message ?? cause)}`}`)
|
|
188
|
+
} catch {
|
|
189
|
+
/* logger 不可用就算了 */
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** sessionId -> 活着的 agent */
|
|
194
|
+
const live = new Map()
|
|
195
|
+
/** sessionId -> { signature, dispose } */
|
|
196
|
+
const installed = new Map()
|
|
197
|
+
|
|
198
|
+
function viewOf(agent) {
|
|
199
|
+
try {
|
|
200
|
+
return ctx.tools.view(agent)
|
|
201
|
+
} catch (error) {
|
|
202
|
+
warn('tools.view() failed', error)
|
|
203
|
+
return undefined
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 该 agent 真正可以被 restrict 遮掉的名字(全局继承层的工具)。 */
|
|
208
|
+
function restrictableOf(agent) {
|
|
209
|
+
const view = viewOf(agent)
|
|
210
|
+
const names = view?.restrictableNames
|
|
211
|
+
return names instanceof Set ? names : new Set()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 重算并重装该会话的遮罩。
|
|
216
|
+
* 只有「关闭集合非空且 allow 掩码变了」才会真的动注册表,避免无谓的
|
|
217
|
+
* tools/change 抖动(每次重装都会让提示词前缀失效一次)。
|
|
218
|
+
*/
|
|
219
|
+
function syncSession(sessionId) {
|
|
220
|
+
const agent = live.get(sessionId)
|
|
221
|
+
if (agent === undefined) {
|
|
222
|
+
installed.delete(sessionId)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
const disabled = store.disabled(sessionId)
|
|
226
|
+
const restrictable = restrictableOf(agent)
|
|
227
|
+
const allow = [...restrictable].filter((name) => !disabled.has(name))
|
|
228
|
+
const signature = disabled.size === 0 ? 'all' : `allow:${allow.slice().sort().join('\u0000')}`
|
|
229
|
+
const current = installed.get(sessionId)
|
|
230
|
+
if (current !== undefined && current.signature === signature) return
|
|
231
|
+
|
|
232
|
+
if (current !== undefined) {
|
|
233
|
+
try {
|
|
234
|
+
current.dispose()
|
|
235
|
+
} catch (error) {
|
|
236
|
+
warn(`lifting previous restriction failed (${sessionId})`, error)
|
|
237
|
+
}
|
|
238
|
+
installed.delete(sessionId)
|
|
239
|
+
}
|
|
240
|
+
if (disabled.size === 0) return
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
// 关键一行:必须走 agent.ctx,遮罩才只作用于这个 agent。
|
|
244
|
+
const dispose = agent.ctx.tools.restrict({ allow })
|
|
245
|
+
installed.set(sessionId, { signature, dispose })
|
|
246
|
+
} catch (error) {
|
|
247
|
+
warn(`restrict() failed (${sessionId})`, error)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function track(agent) {
|
|
252
|
+
const sessionId = sessionIdOf(agent)
|
|
253
|
+
if (sessionId === undefined) return
|
|
254
|
+
live.set(sessionId, agent)
|
|
255
|
+
syncSession(sessionId)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function forget(agent) {
|
|
259
|
+
const sessionId = sessionIdOf(agent)
|
|
260
|
+
if (sessionId === undefined) return
|
|
261
|
+
live.delete(sessionId)
|
|
262
|
+
installed.delete(sessionId)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- 生命周期接线 -------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
ctx.on('agent/created', (payload) => {
|
|
268
|
+
const agent = payload?.agent ?? payload
|
|
269
|
+
if (agent === undefined || agent === null) return
|
|
270
|
+
try {
|
|
271
|
+
track(agent)
|
|
272
|
+
} catch (error) {
|
|
273
|
+
warn('tracking a created agent failed', error)
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
ctx.on('agent/disposed', (payload) => {
|
|
278
|
+
const agent = payload?.agent ?? payload
|
|
279
|
+
if (agent === undefined || agent === null) return
|
|
280
|
+
try {
|
|
281
|
+
forget(agent)
|
|
282
|
+
} catch (error) {
|
|
283
|
+
warn('forgetting a disposed agent failed', error)
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// 工具集合变化(MCP 服务器连接、插件启停)后 allow 掩码可能过期:重算一次。
|
|
288
|
+
let resyncTimer
|
|
289
|
+
ctx.effect(() => () => clearTimeout(resyncTimer), 'tool-switch: resync timer')
|
|
290
|
+
|
|
291
|
+
ctx.on('tools/change', () => {
|
|
292
|
+
clearTimeout(resyncTimer)
|
|
293
|
+
resyncTimer = setTimeout(() => {
|
|
294
|
+
for (const sessionId of [...live.keys()]) {
|
|
295
|
+
try {
|
|
296
|
+
syncSession(sessionId)
|
|
297
|
+
} catch (error) {
|
|
298
|
+
warn(`resync after tools/change failed (${sessionId})`, error)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}, 250)
|
|
302
|
+
resyncTimer.unref?.()
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// 插件卸载时把遮罩全部撤掉:插件不在,就不该继续改会话能力。
|
|
306
|
+
ctx.effect(
|
|
307
|
+
() => () => {
|
|
308
|
+
for (const [, record] of installed) {
|
|
309
|
+
try {
|
|
310
|
+
record.dispose()
|
|
311
|
+
} catch {
|
|
312
|
+
/* 关闭路径不抛 */
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
installed.clear()
|
|
316
|
+
live.clear()
|
|
317
|
+
},
|
|
318
|
+
'tool-switch: restrictions',
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
// 插件激活前就已经存在的会话也要接上。
|
|
322
|
+
try {
|
|
323
|
+
const existing = ctx.agents.list?.() ?? []
|
|
324
|
+
for (const agent of existing) track(agent)
|
|
325
|
+
} catch (error) {
|
|
326
|
+
warn('enumerating live agents failed', error)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// --- 只读目录 / 读写状态 ------------------------------------------------
|
|
330
|
+
|
|
331
|
+
function descriptionOf(name, fallback) {
|
|
332
|
+
const raw = typeof fallback === 'string' && fallback.length > 0 ? fallback : ''
|
|
333
|
+
const firstLine = raw.split(/\r?\n/, 1)[0]?.trim() ?? ''
|
|
334
|
+
if (firstLine.length === 0) return ''
|
|
335
|
+
return firstLine.length > 160 ? `${firstLine.slice(0, 157)}…` : firstLine
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function buildState(sessionId) {
|
|
339
|
+
const agent = live.get(sessionId)
|
|
340
|
+
const view = agent === undefined ? undefined : viewOf(agent)
|
|
341
|
+
|
|
342
|
+
const descriptions = new Map()
|
|
343
|
+
let globalNames = []
|
|
344
|
+
try {
|
|
345
|
+
const definitions = ctx.tools.definitions()
|
|
346
|
+
if (Array.isArray(definitions)) {
|
|
347
|
+
globalNames = definitions.map((definition) => definition?.name).filter((entry) => typeof entry === 'string')
|
|
348
|
+
for (const definition of definitions) {
|
|
349
|
+
if (typeof definition?.name === 'string') descriptions.set(definition.name, definition.description ?? '')
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} catch (error) {
|
|
353
|
+
warn('reading the tool catalog failed', error)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const knownNames = view?.knownNames instanceof Set ? [...view.knownNames] : globalNames
|
|
357
|
+
const restrictable = view?.restrictableNames instanceof Set ? view.restrictableNames : new Set(globalNames)
|
|
358
|
+
const disabled = store.disabled(sessionId)
|
|
359
|
+
|
|
360
|
+
const groups = new Map()
|
|
361
|
+
let enabledCount = 0
|
|
362
|
+
let fixedCount = 0
|
|
363
|
+
const missing = []
|
|
364
|
+
for (const name of disabled) if (!knownNames.includes(name)) missing.push(name)
|
|
365
|
+
|
|
366
|
+
for (const name of [...knownNames].sort()) {
|
|
367
|
+
const isRestrictable = restrictable.has(name)
|
|
368
|
+
const enabled = !isRestrictable || !disabled.has(name)
|
|
369
|
+
if (enabled) enabledCount += 1
|
|
370
|
+
if (!isRestrictable) fixedCount += 1
|
|
371
|
+
const group = groupOf(name)
|
|
372
|
+
let bucket = groups.get(group.id)
|
|
373
|
+
if (bucket === undefined) {
|
|
374
|
+
bucket = { id: group.id, label: group.label, order: group.order, tools: [] }
|
|
375
|
+
groups.set(group.id, bucket)
|
|
376
|
+
}
|
|
377
|
+
bucket.tools.push({
|
|
378
|
+
name,
|
|
379
|
+
label: group.tool,
|
|
380
|
+
description: descriptionOf(name, descriptions.get(name)),
|
|
381
|
+
enabled,
|
|
382
|
+
locked: !isRestrictable,
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const ordered = [...groups.values()].sort((left, right) => left.order - right.order || left.label.localeCompare(right.label))
|
|
387
|
+
for (const group of ordered) group.tools.sort((left, right) => left.label.localeCompare(right.label))
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
ok: true,
|
|
391
|
+
session: sessionId,
|
|
392
|
+
live: agent !== undefined,
|
|
393
|
+
mode: disabled.size === 0 ? 'all' : 'custom',
|
|
394
|
+
disabled: [...disabled].sort(),
|
|
395
|
+
missing,
|
|
396
|
+
counts: { total: knownNames.length, enabled: enabledCount, disabled: knownNames.length - enabledCount, fixed: fixedCount },
|
|
397
|
+
groups: ordered,
|
|
398
|
+
updatedAt: new Date().toISOString(),
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// --- HTTP 路由 ---------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
function send(res, status, payload) {
|
|
405
|
+
const body = `${JSON.stringify(payload)}\n`
|
|
406
|
+
res.writeHead(status, {
|
|
407
|
+
'content-type': 'application/json; charset=utf-8',
|
|
408
|
+
'cache-control': 'no-store',
|
|
409
|
+
'x-content-type-options': 'nosniff',
|
|
410
|
+
})
|
|
411
|
+
res.end(body)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function readJsonBody(req) {
|
|
415
|
+
const chunks = []
|
|
416
|
+
let size = 0
|
|
417
|
+
for await (const chunk of req) {
|
|
418
|
+
size += chunk.length
|
|
419
|
+
if (size > MAX_BODY_BYTES) throw new Error('请求体过大')
|
|
420
|
+
chunks.push(chunk)
|
|
421
|
+
}
|
|
422
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
423
|
+
if (text.length === 0) return {}
|
|
424
|
+
const parsed = JSON.parse(text)
|
|
425
|
+
if (parsed === null || typeof parsed !== 'object') throw new Error('请求体必须是 JSON 对象')
|
|
426
|
+
return parsed
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const handler = async (req, res) => {
|
|
430
|
+
try {
|
|
431
|
+
const url = new URL(req.url ?? ROUTE, 'http://localhost')
|
|
432
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
433
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
434
|
+
if (sessionId.length === 0) {
|
|
435
|
+
send(res, 400, { ok: false, error: 'missing ?session=<id>' })
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
send(res, 200, buildState(sessionId))
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
if (req.method !== 'POST') {
|
|
442
|
+
const body = `${JSON.stringify({ ok: false, error: `method not allowed: ${req.method}` })}\n`
|
|
443
|
+
res.writeHead(405, {
|
|
444
|
+
allow: 'GET, HEAD, POST',
|
|
445
|
+
'content-type': 'application/json; charset=utf-8',
|
|
446
|
+
'cache-control': 'no-store',
|
|
447
|
+
})
|
|
448
|
+
res.end(body)
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const body = await readJsonBody(req)
|
|
453
|
+
const sessionId = typeof body.session === 'string' ? body.session : ''
|
|
454
|
+
if (sessionId.length === 0) {
|
|
455
|
+
send(res, 400, { ok: false, error: 'missing session' })
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
if (!Array.isArray(body.disabled)) {
|
|
459
|
+
send(res, 400, { ok: false, error: 'missing disabled[]' })
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
// 走同一个写入路径:过滤掉「永远不许关」的 tool_switch,再重装遮罩。
|
|
463
|
+
send(res, 200, applySelection(sessionId, body.disabled))
|
|
464
|
+
} catch (error) {
|
|
465
|
+
try {
|
|
466
|
+
send(res, 500, { ok: false, error: String(error?.message ?? error) })
|
|
467
|
+
} catch {
|
|
468
|
+
/* 响应已经开始写了 */
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
474
|
+
webCtx.effect(() => webCtx.webServer.register({ kind: 'exact', path: ROUTE, handler }), 'tool-switch: state route')
|
|
475
|
+
try {
|
|
476
|
+
ctx.logger?.info?.(`[tool-switch] ready — ${ROUTE} on ${webCtx.webServer.getOrigin?.() ?? 'webServer'}`)
|
|
477
|
+
} catch {
|
|
478
|
+
/* origin 只在绑定后可用 */
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// 不依赖 UI 的两条通道
|
|
484
|
+
//
|
|
485
|
+
// 桌面版/网页版靠 composer 下那个按钮;但 CLI、QQ 这类入口没有按钮,也没有
|
|
486
|
+
// 一定存在的 webServer。所以同一个选择逻辑再开两条口:
|
|
487
|
+
// · 模型可调用的工具 `tool_switch`(任何入口都通,用户在聊天里说一句就行)
|
|
488
|
+
// · 斜杠命令 `/tools`(有命令适配器的交互式入口,直接给用户用,不进模型上下文)
|
|
489
|
+
// 三者写的是同一份 store、走同一个 syncSession,选哪个都一样生效。
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
/** 这个会话现在能看见的工具(按 agent 视图)。 */
|
|
493
|
+
function catalogOf(sessionId) {
|
|
494
|
+
const state = buildState(sessionId)
|
|
495
|
+
const tools = []
|
|
496
|
+
for (const group of state.groups) {
|
|
497
|
+
for (const tool of group.tools) {
|
|
498
|
+
tools.push({ name: tool.name, group: group.id, label: group.label, locked: tool.locked })
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return { state, tools }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* 把用户/模型写的目标解析成工具名集合。
|
|
506
|
+
* 支持:all、分组 id(shell / mcp:rea / x:comfyui)、分组标签(终端与执行 / rea / rea · MCP)、
|
|
507
|
+
* 精确工具名、前缀(mcp__rea__)、通配(mcp__rea__*)。
|
|
508
|
+
*/
|
|
509
|
+
function resolveTargets(sessionId, spec) {
|
|
510
|
+
const needle = String(spec ?? '').trim()
|
|
511
|
+
if (needle.length === 0) return { error: '缺少目标' }
|
|
512
|
+
const { tools } = catalogOf(sessionId)
|
|
513
|
+
const restrictable = tools.filter((tool) => !tool.locked)
|
|
514
|
+
if (restrictable.length === 0) return { error: '这个会话没有可开关的工具' }
|
|
515
|
+
|
|
516
|
+
if (/^all$/i.test(needle) || needle === '全部') {
|
|
517
|
+
return { names: restrictable.map((tool) => tool.name), label: '全部可关闭工具' }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const byName = restrictable.filter((tool) => tool.name === needle)
|
|
521
|
+
if (byName.length > 0) return { names: byName.map((tool) => tool.name), label: needle }
|
|
522
|
+
|
|
523
|
+
const normalise = (text) => text.toLowerCase().replace(/\s+/g, '').replace(/·/g, '').replace(/mcp$/u, '')
|
|
524
|
+
const wanted = normalise(needle)
|
|
525
|
+
const groups = new Map()
|
|
526
|
+
for (const tool of restrictable) {
|
|
527
|
+
const list = groups.get(tool.group) ?? []
|
|
528
|
+
list.push(tool.name)
|
|
529
|
+
groups.set(tool.group, list)
|
|
530
|
+
}
|
|
531
|
+
const groupIds = [...groups.keys()]
|
|
532
|
+
const hitGroup = groupIds.find((id) => id.toLowerCase() === needle.toLowerCase())
|
|
533
|
+
?? groupIds.find((id) => normalise(id) === wanted)
|
|
534
|
+
?? groupIds.find((id) => id.startsWith('mcp:') && normalise(id.slice(4)) === wanted)
|
|
535
|
+
if (hitGroup !== undefined) {
|
|
536
|
+
const label = restrictable.find((tool) => tool.group === hitGroup)?.label ?? hitGroup
|
|
537
|
+
return { names: groups.get(hitGroup), label }
|
|
538
|
+
}
|
|
539
|
+
const hitLabel = [...new Set(restrictable.map((tool) => tool.label))]
|
|
540
|
+
.find((label) => normalise(label) === wanted)
|
|
541
|
+
if (hitLabel !== undefined) {
|
|
542
|
+
return { names: restrictable.filter((tool) => tool.label === hitLabel).map((tool) => tool.name), label: hitLabel }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (needle.includes('*')) {
|
|
546
|
+
const pattern = new RegExp(`^${needle.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`)
|
|
547
|
+
const names = restrictable.filter((tool) => pattern.test(tool.name)).map((tool) => tool.name)
|
|
548
|
+
if (names.length > 0) return { names, label: needle }
|
|
549
|
+
return { error: `「${needle}」没有匹配到工具` }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const prefixed = restrictable.filter((tool) => tool.name.startsWith(needle)).map((tool) => tool.name)
|
|
553
|
+
if (prefixed.length > 0) return { names: prefixed, label: `${needle}*` }
|
|
554
|
+
|
|
555
|
+
return { error: `「${needle}」没有匹配到工具或分组(用 /tools 看分组名)` }
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** 写选择并立刻重装遮罩。`tool_switch` 自己永不被关掉,免得把唯一的入口关了。 */
|
|
559
|
+
function applySelection(sessionId, disabled) {
|
|
560
|
+
const next = new Set()
|
|
561
|
+
for (const name of disabled) if (name !== SWITCH_TOOL_NAME) next.add(name)
|
|
562
|
+
store.set(sessionId, next)
|
|
563
|
+
syncSession(sessionId)
|
|
564
|
+
return buildState(sessionId)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function summaryLines(sessionId) {
|
|
568
|
+
const state = buildState(sessionId)
|
|
569
|
+
const lines = []
|
|
570
|
+
for (const group of state.groups) {
|
|
571
|
+
const closeable = group.tools.filter((tool) => !tool.locked)
|
|
572
|
+
const off = closeable.filter((tool) => !tool.enabled).length
|
|
573
|
+
const closed = closeable.length > 0 && off === closeable.length
|
|
574
|
+
lines.push(` ${closed ? '✕' : '·'} ${group.label} ${group.tools.length - off}/${group.tools.length}`)
|
|
575
|
+
}
|
|
576
|
+
return { state, lines }
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function summaryText(sessionId) {
|
|
580
|
+
const { state, lines } = summaryLines(sessionId)
|
|
581
|
+
return [
|
|
582
|
+
`工具:${state.counts.enabled}/${state.counts.total} 可用${state.mode === 'custom' ? '(有工具被关)' : ''}`,
|
|
583
|
+
...lines,
|
|
584
|
+
'用法:/tools off <分组|工具|前缀> | /tools on <…> | /tools all on | /tools all off',
|
|
585
|
+
].join('\n')
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** 工具执行时拿到的 agent:优先用 exec 上的,其次用驱动链上的发起者。 */
|
|
589
|
+
function agentOf(exec) {
|
|
590
|
+
if (exec?.agent !== undefined && exec.agent !== null) return exec.agent
|
|
591
|
+
try {
|
|
592
|
+
return ctx.agents.currentInitiator?.()
|
|
593
|
+
} catch {
|
|
594
|
+
return undefined
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* 执行一个开关动作。action: list | off | on | reset。
|
|
600
|
+
* 返回给命令/工具的文本与状态。
|
|
601
|
+
*/
|
|
602
|
+
function runAction(sessionId, action, target) {
|
|
603
|
+
const verb = String(action ?? '').trim().toLowerCase()
|
|
604
|
+
if (verb === '' || verb === 'list') return { ok: true, state: buildState(sessionId), text: summaryText(sessionId) }
|
|
605
|
+
|
|
606
|
+
const disabled = store.disabled(sessionId)
|
|
607
|
+
|
|
608
|
+
if (verb === 'reset') {
|
|
609
|
+
const state = applySelection(sessionId, [])
|
|
610
|
+
return { ok: true, state, text: `已恢复全部启用(${state.counts.total}/${state.counts.total})` }
|
|
611
|
+
}
|
|
612
|
+
if (verb !== 'off' && verb !== 'on') return { ok: false, text: `不认识的动作「${action}」(用 list/off/on/reset)` }
|
|
613
|
+
|
|
614
|
+
const specs = String(target ?? '').trim().split(/[\s,]+/).filter((part) => part.length > 0)
|
|
615
|
+
if (specs.length === 0) return { ok: false, text: `要 ${verb} 什么?例如 /tools ${verb} rea` }
|
|
616
|
+
|
|
617
|
+
const applied = []
|
|
618
|
+
for (const spec of specs) {
|
|
619
|
+
const resolved = resolveTargets(sessionId, spec)
|
|
620
|
+
if (resolved.error !== undefined) return { ok: false, text: resolved.error }
|
|
621
|
+
for (const name of resolved.names) {
|
|
622
|
+
if (verb === 'off') disabled.add(name)
|
|
623
|
+
else disabled.delete(name)
|
|
624
|
+
}
|
|
625
|
+
applied.push(`${resolved.label}(${resolved.names.length})`)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const state = applySelection(sessionId, disabled)
|
|
629
|
+
const now = verb === 'off' ? '已关闭' : '已启用'
|
|
630
|
+
return {
|
|
631
|
+
ok: true,
|
|
632
|
+
state,
|
|
633
|
+
text: `${now}:${applied.join('、')} → 现在 ${state.counts.enabled}/${state.counts.total} 可用(下一条消息生效)`,
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// --- 斜杠命令(有命令适配器的入口:CLI / 网页 composer) -------------------
|
|
638
|
+
ctx.inject(['commands'], (commandCtx) => {
|
|
639
|
+
commandCtx.effect(
|
|
640
|
+
() =>
|
|
641
|
+
commandCtx.commands.register({
|
|
642
|
+
name: 'tools',
|
|
643
|
+
description: '查看或开关本会话里模型可用的工具',
|
|
644
|
+
input: { hint: '[off|on] <分组|工具名|前缀> | all on|all off | reset' },
|
|
645
|
+
handler: ({ agent, rawInput }) => {
|
|
646
|
+
const sessionId = sessionIdOf(agent)
|
|
647
|
+
if (sessionId === undefined) return { kind: 'error', text: '拿不到会话 id' }
|
|
648
|
+
const input = String(rawInput ?? '').trim()
|
|
649
|
+
// `all on` / `all off` 这种「动词在前」的写法也认
|
|
650
|
+
const allFirst = /^(all|全部)\s+(on|off|启用|关闭)$/i.exec(input)
|
|
651
|
+
const [word, ...rest] = input.split(/\s+/)
|
|
652
|
+
const result = allFirst !== null
|
|
653
|
+
? runAction(sessionId, allFirst[2].toLowerCase().replace('启用', 'on').replace('关闭', 'off'), 'all')
|
|
654
|
+
: runAction(sessionId, word === undefined ? 'list' : word, rest.join(' '))
|
|
655
|
+
return { kind: result.ok ? 'success' : 'error', text: result.text }
|
|
656
|
+
},
|
|
657
|
+
}),
|
|
658
|
+
'tool-switch: /tools command',
|
|
659
|
+
)
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
// --- 模型可调用的工具(任何入口都通,包括 QQ / headless) -------------------
|
|
663
|
+
try {
|
|
664
|
+
const definition = {
|
|
665
|
+
name: SWITCH_TOOL_NAME,
|
|
666
|
+
description:
|
|
667
|
+
'查看或开关当前会话里模型可用的工具。用户说「把 XX 类工具关掉/打开」时用它;关掉的工具会从工具清单里消失,下一条消息生效。',
|
|
668
|
+
parameters: {
|
|
669
|
+
type: 'object',
|
|
670
|
+
properties: {
|
|
671
|
+
action: {
|
|
672
|
+
type: 'string',
|
|
673
|
+
enum: ['list', 'off', 'on', 'reset'],
|
|
674
|
+
description: 'list 只看;off 关闭;on 启用;reset 全部启用',
|
|
675
|
+
},
|
|
676
|
+
target: {
|
|
677
|
+
type: 'string',
|
|
678
|
+
description: '分组名(如 rea、终端与执行、mcp:rea)、精确工具名、前缀(mcp__rea__)或 all;list/reset 可省略',
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
required: ['action'],
|
|
682
|
+
additionalProperties: false,
|
|
683
|
+
},
|
|
684
|
+
output: {
|
|
685
|
+
schema: {
|
|
686
|
+
type: 'object',
|
|
687
|
+
properties: {
|
|
688
|
+
ok: { type: 'boolean' },
|
|
689
|
+
action: { type: 'string' },
|
|
690
|
+
enabled: { type: 'number' },
|
|
691
|
+
total: { type: 'number' },
|
|
692
|
+
message: { type: 'string' },
|
|
693
|
+
},
|
|
694
|
+
required: ['ok', 'message'],
|
|
695
|
+
additionalProperties: false,
|
|
696
|
+
},
|
|
697
|
+
render: (_args, value) => [{ type: 'text', text: String(value?.message ?? '') }],
|
|
698
|
+
},
|
|
699
|
+
async execute(args, exec) {
|
|
700
|
+
const sessionId = sessionIdOf(agentOf(exec) ?? {})
|
|
701
|
+
if (sessionId === undefined) {
|
|
702
|
+
return { ok: false, action: String(args?.action ?? ''), message: '拿不到当前会话,无法改工具开关' }
|
|
703
|
+
}
|
|
704
|
+
const result = runAction(sessionId, args?.action, args?.target)
|
|
705
|
+
return {
|
|
706
|
+
ok: result.ok,
|
|
707
|
+
action: String(args?.action ?? ''),
|
|
708
|
+
enabled: result.state?.counts?.enabled ?? 0,
|
|
709
|
+
total: result.state?.counts?.total ?? 0,
|
|
710
|
+
message: result.text,
|
|
711
|
+
}
|
|
712
|
+
},
|
|
713
|
+
}
|
|
714
|
+
ctx.effect(() => ctx.tools.register(definition), 'tool-switch: tool_switch tool')
|
|
715
|
+
} catch (error) {
|
|
716
|
+
warn('registering tool_switch failed', error)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|