llm-api-gateway-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -0
- package/README.md +1127 -0
- package/cli-agent.js +666 -0
- package/cli-anthropic.js +236 -0
- package/cli-claude-code.js +317 -0
- package/cli-openai.js +212 -0
- package/completions/_llm-api-gateway-cli +65 -0
- package/completions/llm-api-gateway-cli.bash +64 -0
- package/completions/llm-api-gateway-cli.fish +43 -0
- package/images/chat.png +0 -0
- package/images/settings.png +0 -0
- package/images/task.png +0 -0
- package/lib/agent.js +607 -0
- package/lib/commands.js +468 -0
- package/lib/common.js +196 -0
- package/lib/config.js +70 -0
- package/lib/configcmd.js +230 -0
- package/lib/hub.js +1494 -0
- package/lib/jsonstore.js +49 -0
- package/lib/mcp.js +375 -0
- package/lib/memory.js +109 -0
- package/lib/plandoc.js +178 -0
- package/lib/pricing.js +52 -0
- package/lib/runner.js +234 -0
- package/lib/runstore.js +96 -0
- package/lib/secrets.js +198 -0
- package/lib/sessionstore.js +269 -0
- package/lib/settings.js +517 -0
- package/lib/tasksession.js +594 -0
- package/lib/taskstore.js +740 -0
- package/lib/tools.js +927 -0
- package/package.json +55 -0
- package/public/app.js +1055 -0
- package/public/index.html +167 -0
- package/public/manual.css +215 -0
- package/public/manual.html +381 -0
- package/public/manual.js +186 -0
- package/public/models.js +121 -0
- package/public/render.js +250 -0
- package/public/styles.css +955 -0
- package/public/task-slash.js +493 -0
- package/public/task.css +739 -0
- package/public/task.html +220 -0
- package/public/task.js +3127 -0
- package/public/theme.js +91 -0
- package/public/tint.js +261 -0
- package/scripts/install.ps1 +537 -0
- package/scripts/install.sh +510 -0
- package/server.js +14 -0
- package/task-server.js +15 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务页的斜杠指令(E 组:计划与审批 / 模型与请求参数 / 会话与用量 / 工作目录 / 手册)
|
|
3
|
+
*
|
|
4
|
+
* 与另外两端的口径对齐(见网关仓库 docs/SLASH-CLI-ALIGNMENT-20260916.md):
|
|
5
|
+
* · 字段名与网关 `js/slash.js`、CLI `lib/commands.js` 同一套:name / aliases / args /
|
|
6
|
+
* needsArgs / group / capability / surfaces / help / examples / run;
|
|
7
|
+
* · `capability`(能不能跑:local / api / fs)与 `surfaces`(在哪一端有意义)正交,
|
|
8
|
+
* 本文件里的指令 surfaces 一律 ['hub'] —— 它们依赖"有工作目录 + 有待审批的挂起运行",
|
|
9
|
+
* 这两样只有任务页有:网关对话页没有工作目录,CLI REPL 的审批是同步 y/N;
|
|
10
|
+
* · 输出一律纯文本(`print` 走 textContent),不做 Markdown 渲染:文件名等来自文件系统,
|
|
11
|
+
* 不该有机会变成 HTML;帮助表格因此排成对齐的列,而不是 Markdown 表格。
|
|
12
|
+
*
|
|
13
|
+
* 端内差异(与网关对话页的 R2 不同):
|
|
14
|
+
* 未知的 `/xxx` **不会被当成普通消息发给模型**,而是提示"任务页没有这条指令,要当文本发就写 //xxx"。
|
|
15
|
+
* 理由:在任务页把 `/clear` 当任务描述发给编码 Agent 几乎不可能是本意;而 REPL 也是这个口径,
|
|
16
|
+
* 两端的 CLI 家族先保持一致。想原样发给模型,写 `//clear`。
|
|
17
|
+
*
|
|
18
|
+
* 另一个端内差异:`suggest('')` 这里返回**全部指令**(任务页有候选面板,敲一个 `/` 就该看见有哪些),
|
|
19
|
+
* 而 CLI 的 `lib/commands.js:suggest('')` 返回空(REPL 没有面板,敲 `/` 就糊一屏候选是噪音)。
|
|
20
|
+
* 同名的 `suggest()` 在两端的"输入体验"上不同,是**有意的**;表本身仍然只有一份。
|
|
21
|
+
*
|
|
22
|
+
* 指令表是手册页(/manual)与服务端 `/api/commands` 的**唯一出处**:`commandRows()` 输出的字段
|
|
23
|
+
* 与 `lib/commands.js:commandRows()` 对齐,手册页因此不会各抄一份清单(抄的那份一定会漂)。
|
|
24
|
+
*
|
|
25
|
+
* 这个文件不碰 DOM(只管"该做什么"),DOM 与事件留给 task.js:这样才能在 node 里直接测。
|
|
26
|
+
*/
|
|
27
|
+
(function (root, factory) {
|
|
28
|
+
root.TaskSlash = factory();
|
|
29
|
+
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
30
|
+
const CAPABILITIES = ['local', 'api', 'fs'];
|
|
31
|
+
const SURFACES = ['web', 'cli', 'hub'];
|
|
32
|
+
|
|
33
|
+
/** 按空白切分,支持双引号包住带空格的参数;引号未闭合 → 抛错(调用方转成指令错误) */
|
|
34
|
+
function tokenize(s) {
|
|
35
|
+
const out = [];
|
|
36
|
+
const raw = String(s == null ? '' : s);
|
|
37
|
+
let i = 0;
|
|
38
|
+
while (i < raw.length) {
|
|
39
|
+
while (i < raw.length && /\s/.test(raw[i])) i++;
|
|
40
|
+
if (i >= raw.length) break;
|
|
41
|
+
if (raw[i] === '"') {
|
|
42
|
+
const end = raw.indexOf('"', i + 1);
|
|
43
|
+
if (end < 0) throw new Error('引号没有闭合(带空格的参数用 " 包起来)');
|
|
44
|
+
out.push(raw.slice(i + 1, end));
|
|
45
|
+
i = end + 1;
|
|
46
|
+
} else {
|
|
47
|
+
let j = i;
|
|
48
|
+
while (j < raw.length && !/\s/.test(raw[j])) j++;
|
|
49
|
+
out.push(raw.slice(i, j));
|
|
50
|
+
i = j;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const usageLine = (c) => '/' + c.name + (c.args && c.args !== '—' ? ' ' + c.args : '');
|
|
57
|
+
|
|
58
|
+
/* ============================ 指令表 ============================ */
|
|
59
|
+
|
|
60
|
+
const COMMANDS = [
|
|
61
|
+
{
|
|
62
|
+
name: 'help',
|
|
63
|
+
aliases: ['?', 'h'],
|
|
64
|
+
args: '[指令名]',
|
|
65
|
+
group: '帮助',
|
|
66
|
+
capability: 'local',
|
|
67
|
+
surfaces: ['hub'],
|
|
68
|
+
help: '显示这份清单,或某条指令的用法',
|
|
69
|
+
examples: ['/help', '/help init'],
|
|
70
|
+
run: (ctx, argv) => helpText(argv[0], { surface: 'hub', hasFileSystem: ctx.hasFileSystem() }),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'plan',
|
|
74
|
+
aliases: ['p'],
|
|
75
|
+
args: '[任务描述]',
|
|
76
|
+
group: '计划与审批',
|
|
77
|
+
capability: 'local',
|
|
78
|
+
surfaces: ['hub'],
|
|
79
|
+
help: '切到计划模式(模型只有只读工具,先出计划、不动文件);带描述则立即按计划模式跑一轮',
|
|
80
|
+
examples: ['/plan', '/plan 先看下项目结构再给我改造方案'],
|
|
81
|
+
run: async (ctx, argv) => {
|
|
82
|
+
if (ctx.busy()) return '正在跑这一轮,先等它结束(或点「停止」)—— 切模式对已经在跑的这轮无效。';
|
|
83
|
+
ctx.setMode('plan');
|
|
84
|
+
const task = argv.join(' ').trim();
|
|
85
|
+
if (!task) return '已切到计划模式:这一轮模型只能读、不能写。看完计划后点「按计划开始执行」再动手。';
|
|
86
|
+
// 不 await:这一轮可能跑好几分钟,回执先给出来
|
|
87
|
+
ctx.send(task).catch(() => {});
|
|
88
|
+
return '已按计划模式开始这一轮(只读工具,不会改文件)。';
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'approve',
|
|
93
|
+
aliases: ['ok'],
|
|
94
|
+
args: '—',
|
|
95
|
+
group: '计划与审批',
|
|
96
|
+
capability: 'local',
|
|
97
|
+
surfaces: ['hub'],
|
|
98
|
+
help: '批准当前挂起的写入(等同卡片上的「批准」按钮)',
|
|
99
|
+
examples: ['/approve'],
|
|
100
|
+
run: async (ctx) => {
|
|
101
|
+
if (ctx.busy()) return '正在跑这一轮,等它停下来再批准(或者点「停止」)。';
|
|
102
|
+
if (!ctx.pending()) return '当前没有待批准的写入。';
|
|
103
|
+
ctx.decide(true).catch(() => {});
|
|
104
|
+
return '已批准,继续跑。';
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'reject',
|
|
109
|
+
aliases: ['no'],
|
|
110
|
+
args: '—',
|
|
111
|
+
group: '计划与审批',
|
|
112
|
+
capability: 'local',
|
|
113
|
+
surfaces: ['hub'],
|
|
114
|
+
help: '拒绝当前挂起的写入(等同卡片上的「拒绝」按钮)',
|
|
115
|
+
examples: ['/reject'],
|
|
116
|
+
run: async (ctx) => {
|
|
117
|
+
if (ctx.busy()) return '正在跑这一轮,等它停下来再拒绝(或者点「停止」)。';
|
|
118
|
+
if (!ctx.pending()) return '当前没有待批准的写入。';
|
|
119
|
+
ctx.decide(false).catch(() => {});
|
|
120
|
+
return '已拒绝这次写入。';
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'mode',
|
|
125
|
+
aliases: [],
|
|
126
|
+
args: '[manual|auto|plan]',
|
|
127
|
+
group: '计划与审批',
|
|
128
|
+
capability: 'local',
|
|
129
|
+
surfaces: ['hub'],
|
|
130
|
+
help: '查看或切换审批模式(手动 / 自动 / 计划)—— 与页面上的模式按钮同一实现',
|
|
131
|
+
examples: ['/mode', '/mode auto'],
|
|
132
|
+
run: (ctx, argv) => {
|
|
133
|
+
const want = String(argv[0] || '').toLowerCase();
|
|
134
|
+
if (!want) return '当前模式:' + ctx.mode() + '\n用法:/mode manual|auto|plan(切模式不影响已经在跑的这一轮)';
|
|
135
|
+
return '已切到' + ctx.setMode(want) + '模式。';
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'model',
|
|
140
|
+
aliases: [],
|
|
141
|
+
args: '[模型名]',
|
|
142
|
+
group: '模型与请求参数',
|
|
143
|
+
capability: 'fs', // 写进磁盘上的配置文件(PUT /api/settings)
|
|
144
|
+
surfaces: ['hub'],
|
|
145
|
+
help: '查看可选模型或切换当前模型(只能从网关 /v1/models 拉到的列表里选,与设置面板同一项)',
|
|
146
|
+
examples: ['/model', '/model deepseek-v4-pro'],
|
|
147
|
+
run: async (ctx, argv) => {
|
|
148
|
+
const want = argv.join(' ').trim();
|
|
149
|
+
const list = typeof ctx.models === 'function' ? ctx.models() : [];
|
|
150
|
+
if (!want) {
|
|
151
|
+
return [
|
|
152
|
+
'当前模型:' + (ctx.model() || '(未指定,用服务端默认)'),
|
|
153
|
+
list.length
|
|
154
|
+
? '可选(来自网关 /v1/models,只能选、不能填):\n' + list.map((m) => ' ' + m).join('\n')
|
|
155
|
+
: '还没拿到模型列表:先在设置面板点「刷新模型」。',
|
|
156
|
+
'用法:/model <模型名>(与设置面板里的「模型」同一项)',
|
|
157
|
+
].join('\n');
|
|
158
|
+
}
|
|
159
|
+
// 与设置面板同一口径:只认列表里的名字(当前值例外 —— 它可能是继承下来的旧配置)
|
|
160
|
+
const hit = list.find((m) => String(m).toLowerCase() === want.toLowerCase()) || null;
|
|
161
|
+
const isCurrent = String(ctx.model() || '').toLowerCase() === want.toLowerCase();
|
|
162
|
+
if (!hit && !isCurrent) {
|
|
163
|
+
if (!list.length) throw new Error('没有拿到模型列表,不能手填模型名;先在设置面板点「刷新模型」');
|
|
164
|
+
throw new Error('模型不在网关返回的列表里:' + want + '\n可选:' + list.join(' / '));
|
|
165
|
+
}
|
|
166
|
+
const next = hit || want;
|
|
167
|
+
await ctx.setModel(next);
|
|
168
|
+
return '模型已切换为 ' + next + '(设置已存盘,下一步生效)。';
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'cost',
|
|
173
|
+
aliases: [],
|
|
174
|
+
args: '—',
|
|
175
|
+
group: '会话与用量',
|
|
176
|
+
capability: 'api', // 单价口径在服务端(GET /api/cost → lib/pricing.js)
|
|
177
|
+
surfaces: ['hub'],
|
|
178
|
+
help: '查看这条任务累计 token 与费用粗估(口径与 CLI 的 /cost 同一份单价表)',
|
|
179
|
+
examples: ['/cost'],
|
|
180
|
+
run: async (ctx) => ctx.cost(),
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: 'resume',
|
|
184
|
+
aliases: [],
|
|
185
|
+
args: '[任务id]',
|
|
186
|
+
group: '会话与用量',
|
|
187
|
+
capability: 'fs', // 任务记录在磁盘上(/api/tasks)
|
|
188
|
+
surfaces: ['hub'],
|
|
189
|
+
help: '切到另一条已保存的任务(不带参数列出最近任务)—— 等同于点侧边栏',
|
|
190
|
+
examples: ['/resume', '/resume 5f2c1a9e-...'],
|
|
191
|
+
run: async (ctx, argv) => {
|
|
192
|
+
const id = String(argv[0] || '').trim();
|
|
193
|
+
const list = ctx.tasks();
|
|
194
|
+
if (!id) {
|
|
195
|
+
if (!list.length) return '还没有已保存的任务(侧边栏「+ 新任务」可以开一条)。';
|
|
196
|
+
const rows = list.slice(0, 10).map((t) =>
|
|
197
|
+
' ' + t.id + ' ' + (t.title || '新任务') + ' ' + (t.workDir || '—') + ' ' + fmtTime(t.updatedAt));
|
|
198
|
+
return '最近的任务:\n' + rows.join('\n') + '\n用 /resume <任务id> 切过去(也可以直接点侧边栏)。';
|
|
199
|
+
}
|
|
200
|
+
const t = list.find((x) => x.id === id);
|
|
201
|
+
if (!t) return '找不到任务 ' + id + '(用 /resume 不带参数列出最近的任务)。';
|
|
202
|
+
await ctx.switchTask(id);
|
|
203
|
+
return '已切换到「' + (t.title || '新任务') + '」。';
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: 'stop',
|
|
208
|
+
aliases: [],
|
|
209
|
+
args: '—',
|
|
210
|
+
group: '会话与用量',
|
|
211
|
+
capability: 'local',
|
|
212
|
+
surfaces: ['hub'],
|
|
213
|
+
help: '停止正在跑的这一轮(等同页面上的「停止」按钮;后台其它任务不受影响)',
|
|
214
|
+
examples: ['/stop'],
|
|
215
|
+
run: (ctx) => {
|
|
216
|
+
if (!ctx.busy()) return '当前没有在跑的这一轮(停止只作用于这条任务正在跑的那一轮)。';
|
|
217
|
+
ctx.stop();
|
|
218
|
+
return '已请求停止这一轮(后台其它任务不受影响)。';
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: 'files',
|
|
223
|
+
aliases: ['ls'],
|
|
224
|
+
args: '[子路径]',
|
|
225
|
+
group: '工作目录',
|
|
226
|
+
capability: 'fs',
|
|
227
|
+
surfaces: ['hub'],
|
|
228
|
+
help: '列出工作目录(或其中某个子目录)里的文件',
|
|
229
|
+
examples: ['/files', '/files src'],
|
|
230
|
+
run: async (ctx, argv) => {
|
|
231
|
+
const r = await ctx.listFiles(argv[0] || '');
|
|
232
|
+
return r;
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
name: 'cwd',
|
|
237
|
+
aliases: ['pwd'],
|
|
238
|
+
args: '[路径]',
|
|
239
|
+
group: '工作目录',
|
|
240
|
+
capability: 'fs',
|
|
241
|
+
surfaces: ['hub'],
|
|
242
|
+
help: '查看当前工作目录;带路径则校验后切换过去',
|
|
243
|
+
examples: ['/cwd', '/cwd D:\\proj\\demo'],
|
|
244
|
+
run: async (ctx, argv) => {
|
|
245
|
+
const target = argv.join(' ').trim();
|
|
246
|
+
if (!target) return '当前工作目录:' + (ctx.workDir() || '(未选择)');
|
|
247
|
+
const abs = await ctx.switchDir(target);
|
|
248
|
+
return '已切换工作目录:' + abs;
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: 'init',
|
|
253
|
+
aliases: [],
|
|
254
|
+
args: '[--force]',
|
|
255
|
+
group: '工作目录',
|
|
256
|
+
capability: 'fs',
|
|
257
|
+
surfaces: ['hub'],
|
|
258
|
+
help: '在工作目录里生成 AGENTS.md(项目约定骨架);已存在时不覆盖,除非加 --force',
|
|
259
|
+
examples: ['/init', '/init --force'],
|
|
260
|
+
run: async (ctx, argv) => {
|
|
261
|
+
const r = await ctx.init(argv.indexOf('--force') >= 0);
|
|
262
|
+
if (!r.written) return `已经有 ${r.rel} 了,没有动它(要重写骨架就 /init --force)。用 /instructions 看现在的内容。`;
|
|
263
|
+
return `${r.existed ? '已重新生成' : '已生成'} ${r.rel}(${r.bytes} 字节)。把「怎么跑测试」这类规矩补进去,模型下次开工就会先读它。`;
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: 'instructions',
|
|
268
|
+
aliases: ['memory'],
|
|
269
|
+
args: '—',
|
|
270
|
+
group: '工作目录',
|
|
271
|
+
capability: 'fs',
|
|
272
|
+
surfaces: ['hub'],
|
|
273
|
+
help: '显示这个工作目录注入给模型的项目记忆(AGENTS.md / CLAUDE.md)',
|
|
274
|
+
examples: ['/instructions'],
|
|
275
|
+
run: async (ctx) => ctx.instructions(),
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: 'manual',
|
|
279
|
+
aliases: [],
|
|
280
|
+
args: '—',
|
|
281
|
+
group: '手册',
|
|
282
|
+
capability: 'local',
|
|
283
|
+
surfaces: ['hub'],
|
|
284
|
+
help: '打开操作手册页(任务页操作全流程 + 三端斜杠指令对照)',
|
|
285
|
+
examples: ['/manual'],
|
|
286
|
+
run: (ctx) => {
|
|
287
|
+
const url = ctx.manualUrl();
|
|
288
|
+
if (typeof ctx.openManual === 'function') ctx.openManual(url);
|
|
289
|
+
return '操作手册:' + url + '\n(任务页操作全流程 + 三端斜杠指令对照;页面上也可以直接访问这个地址)';
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
/** 时间戳 → 人能读的一行(/resume 列任务用;拿不到就老实写 —) */
|
|
295
|
+
function fmtTime(ts) {
|
|
296
|
+
if (!ts) return '—';
|
|
297
|
+
try {
|
|
298
|
+
return new Date(ts).toLocaleString();
|
|
299
|
+
} catch {
|
|
300
|
+
return '—';
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** 自检:名字/别名唯一、capability/surfaces 合法、必填字段齐全、run 是函数 */
|
|
305
|
+
function assertRegistry(registry) {
|
|
306
|
+
const list = registry || COMMANDS;
|
|
307
|
+
const seen = new Map();
|
|
308
|
+
list.forEach((c) => {
|
|
309
|
+
if (!c || !c.name) throw new Error('指令表:存在没有 name 的条目');
|
|
310
|
+
if (CAPABILITIES.indexOf(c.capability) < 0) throw new Error('/' + c.name + ' 的 capability 非法:' + c.capability);
|
|
311
|
+
if (!Array.isArray(c.surfaces) || !c.surfaces.length) throw new Error('/' + c.name + ' 的 surfaces 必须非空');
|
|
312
|
+
c.surfaces.forEach((s) => {
|
|
313
|
+
if (SURFACES.indexOf(s) < 0) throw new Error('/' + c.name + ' 的 surface 非法:' + s);
|
|
314
|
+
});
|
|
315
|
+
if (!c.group) throw new Error('/' + c.name + ' 缺少 group');
|
|
316
|
+
if (typeof c.run !== 'function') throw new Error('/' + c.name + ' 缺少 run()');
|
|
317
|
+
[c.name].concat(c.aliases || []).forEach((n) => {
|
|
318
|
+
const k = String(n).toLowerCase();
|
|
319
|
+
if (seen.has(k)) throw new Error('指令名冲突:/' + k + ' 属于 /' + seen.get(k) + ' 与 /' + c.name);
|
|
320
|
+
seen.set(k, c.name);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function findCommand(name, registry) {
|
|
327
|
+
const key = String(name == null ? '' : name).toLowerCase();
|
|
328
|
+
return (registry || COMMANDS).find(
|
|
329
|
+
(c) => c.name.toLowerCase() === key || (c.aliases || []).some((a) => String(a).toLowerCase() === key)
|
|
330
|
+
) || null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 端内可用性:任务页只跑 surfaces 含 hub 的指令;fs 类要求服务端放行了本机文件访问 */
|
|
334
|
+
function isAvailable(cmd, opts) {
|
|
335
|
+
const o = opts || {};
|
|
336
|
+
if (!cmd) return false;
|
|
337
|
+
if (!(cmd.surfaces || ['hub']).includes(o.surface || 'hub')) return false;
|
|
338
|
+
if (cmd.capability === 'fs' && !o.hasFileSystem) return false;
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function available(opts) {
|
|
343
|
+
return (opts && opts.registry ? opts.registry : COMMANDS).filter((c) => isAvailable(c, opts));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 前缀候选:主名优先,其次别名。
|
|
348
|
+
* 空前缀返回**全部可用指令**(按表顺序=分组顺序)—— 任务页输入框只敲一个 `/` 时就该看见全貌,
|
|
349
|
+
* 这正是"指令看起来不全"的老毛病:以前返回空数组,面板直接关掉(见文件头「端内差异」)。
|
|
350
|
+
*/
|
|
351
|
+
function suggest(prefix, opts) {
|
|
352
|
+
const p = String(prefix == null ? '' : prefix).toLowerCase();
|
|
353
|
+
if (!p) return available(opts);
|
|
354
|
+
const scored = [];
|
|
355
|
+
available(opts).forEach((c) => {
|
|
356
|
+
const name = c.name.toLowerCase();
|
|
357
|
+
let score = -1;
|
|
358
|
+
if (name === p) score = 0;
|
|
359
|
+
else if (name.startsWith(p)) score = 1;
|
|
360
|
+
else if ((c.aliases || []).some((a) => String(a).toLowerCase().startsWith(p))) score = 2;
|
|
361
|
+
if (score >= 0) scored.push({ c, score, name });
|
|
362
|
+
});
|
|
363
|
+
scored.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name));
|
|
364
|
+
return scored.map((s) => s.c);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 解析一行输入。返回:
|
|
369
|
+
* { isCommand:false } 普通任务
|
|
370
|
+
* { isCommand:false, escaped:true, text:'/x' } `//x` → 当普通任务发出去
|
|
371
|
+
* { isCommand:false, unknownSlash:true, name:'x' } 看着像指令但不认识(**不发给模型**,提示用 //)
|
|
372
|
+
* { isCommand:true, cmd, name, argv } 命中
|
|
373
|
+
* { isCommand:true, error, matches } 引号未闭合 / 名字不完整
|
|
374
|
+
*/
|
|
375
|
+
function parseSlash(text, opts) {
|
|
376
|
+
const raw0 = String(text == null ? '' : text);
|
|
377
|
+
if (/^\s*\/\//.test(raw0)) return { isCommand: false, escaped: true, text: raw0.trim().slice(1) };
|
|
378
|
+
const raw = raw0.replace(/^\s*//, '/');
|
|
379
|
+
const m = raw.match(/^\s*\/([A-Za-z][A-Za-z0-9_-]*)/);
|
|
380
|
+
if (!m) return { isCommand: false };
|
|
381
|
+
const name = m[1].toLowerCase();
|
|
382
|
+
let argv;
|
|
383
|
+
try {
|
|
384
|
+
argv = tokenize(raw.slice(m[0].length));
|
|
385
|
+
} catch (e) {
|
|
386
|
+
return { isCommand: true, name, argv: [], error: (e && e.message) || '参数解析失败' };
|
|
387
|
+
}
|
|
388
|
+
const cmd = findCommand(name, (opts && opts.registry) || COMMANDS);
|
|
389
|
+
if (cmd) {
|
|
390
|
+
if (!isAvailable(cmd, opts)) {
|
|
391
|
+
return { isCommand: true, cmd, name, argv, error: cmd.capability === 'fs' ? '这条指令需要本机文件访问,当前服务未放行' : '这条指令不在任务页' };
|
|
392
|
+
}
|
|
393
|
+
return { isCommand: true, cmd, name, argv };
|
|
394
|
+
}
|
|
395
|
+
const near = suggest(name, opts);
|
|
396
|
+
if (near.length) return { isCommand: true, name, argv, error: '指令名不完整,请补全', matches: near };
|
|
397
|
+
return { isCommand: false, unknownSlash: true, name };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* ============================ 帮助(纯文本,对齐列) ============================ */
|
|
401
|
+
|
|
402
|
+
function helpText(one, opts) {
|
|
403
|
+
if (one) {
|
|
404
|
+
const cmd = findCommand(one, (opts && opts.registry) || COMMANDS);
|
|
405
|
+
if (!cmd) return '没有这条指令:/' + String(one).replace(/^\//, '') + '(用 /help 看全部)';
|
|
406
|
+
const lines = [
|
|
407
|
+
usageLine(cmd) + ' ' + cmd.help,
|
|
408
|
+
' 别名:' + ((cmd.aliases || []).length ? cmd.aliases.map((a) => '/' + a).join(' ') : '—'),
|
|
409
|
+
' 能力:' + cmd.capability + (cmd.capability === 'fs' ? '(需要工作目录)' : '(本页即可)'),
|
|
410
|
+
];
|
|
411
|
+
if ((cmd.examples || []).length) lines.push(' 示例:' + cmd.examples.join(' '));
|
|
412
|
+
return lines.join('\n');
|
|
413
|
+
}
|
|
414
|
+
const list = available(opts);
|
|
415
|
+
const width = Math.max.apply(null, list.map((c) => usageLine(c).length));
|
|
416
|
+
const groups = [];
|
|
417
|
+
list.forEach((c) => {
|
|
418
|
+
if (!groups.includes(c.group)) groups.push(c.group);
|
|
419
|
+
});
|
|
420
|
+
const rows = [];
|
|
421
|
+
groups.forEach((g) => {
|
|
422
|
+
rows.push(' ' + g);
|
|
423
|
+
list.filter((c) => c.group === g).forEach((c) => rows.push(' ' + usageLine(c).padEnd(width) + ' ' + c.help));
|
|
424
|
+
});
|
|
425
|
+
return [
|
|
426
|
+
'任务页的斜杠指令(只影响这个页面,不发模型):',
|
|
427
|
+
rows.join('\n'),
|
|
428
|
+
'',
|
|
429
|
+
'输入 / 弹全部候选(↑↓ 选择 · Tab 补全 · Enter 执行 · Esc 关闭)。',
|
|
430
|
+
'完整手册(含三端对照与操作流程):/manual',
|
|
431
|
+
'想把这些字面发给模型,写成 //xxx(例://plan 就是让它自己规划)。',
|
|
432
|
+
].join('\n');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** 机器可读的指令行(手册页 / 服务端 /api/commands 用;字段与 lib/commands.js:commandRows() 对齐) */
|
|
436
|
+
function commandRows(registry) {
|
|
437
|
+
return (registry || COMMANDS).map((c) => ({
|
|
438
|
+
name: c.name,
|
|
439
|
+
aliases: (c.aliases || []).slice(),
|
|
440
|
+
args: c.args || '—',
|
|
441
|
+
usage: usageLine(c),
|
|
442
|
+
help: c.help,
|
|
443
|
+
group: c.group,
|
|
444
|
+
capability: c.capability,
|
|
445
|
+
surfaces: (c.surfaces || []).slice(),
|
|
446
|
+
}));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/* ============================ 执行 ============================ */
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* 执行一条指令。命中与否都返回 { handled },调用方据此决定还要不要走 /api/task。
|
|
453
|
+
* ctx 需要:setMode(mode) send(text) pending() decide(approved) workDir() mode() stop()
|
|
454
|
+
* listFiles(sub) switchDir(p) init(force) instructions()
|
|
455
|
+
* model() setModel(name) cost() tasks() switchTask(id) manualUrl() [openManual(url)]
|
|
456
|
+
* print(kind, text) hasFileSystem()
|
|
457
|
+
*/
|
|
458
|
+
async function runSlash(parsed, ctx) {
|
|
459
|
+
if (!parsed) return { handled: false };
|
|
460
|
+
// 未知指令要先拦:它在解析结果里是 isCommand:false(因为它不是指令),
|
|
461
|
+
// 但**绝不能**因此落回"当普通任务发出去"那条路 —— 这正是端内差异所在。
|
|
462
|
+
if (parsed.unknownSlash) {
|
|
463
|
+
ctx.print('warn', '任务页没有 /' + parsed.name + ' 这条指令(用 /help 看现有的)。\n要让模型看到这段文字,写成 //' + parsed.name + '。');
|
|
464
|
+
return { handled: true, ok: false };
|
|
465
|
+
}
|
|
466
|
+
if (!parsed.isCommand) return { handled: false };
|
|
467
|
+
if (parsed.error) {
|
|
468
|
+
ctx.print('warn', parsed.error + ((parsed.matches || []).length ? '\n候选:' + parsed.matches.map((c) => usageLine(c).split(' ')[0]).join(' ') : ''));
|
|
469
|
+
return { handled: true, ok: false };
|
|
470
|
+
}
|
|
471
|
+
const cmd = parsed.cmd;
|
|
472
|
+
if (cmd.capability === 'fs' && typeof ctx.hasFileSystem === 'function' && !ctx.hasFileSystem()) {
|
|
473
|
+
ctx.print('warn', '这条指令需要本机文件访问,当前服务未放行(服务没有绑定在本机地址)。');
|
|
474
|
+
return { handled: true, ok: false };
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
const out = await cmd.run(ctx, parsed.argv || []);
|
|
478
|
+
if (typeof out === 'string' && out) ctx.print('ok', out);
|
|
479
|
+
return { handled: true, ok: true, name: cmd.name };
|
|
480
|
+
} catch (e) {
|
|
481
|
+
const msg = e && e.message ? e.message : String(e);
|
|
482
|
+
ctx.print('error', msg + '\n用法:' + usageLine(cmd) + ((cmd.examples || []).length ? ' 例:' + cmd.examples[0] : ''));
|
|
483
|
+
return { handled: true, ok: false, error: e };
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
assertRegistry(COMMANDS);
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
CAPABILITIES, SURFACES, COMMANDS, tokenize, usageLine, findCommand,
|
|
491
|
+
isAvailable, available, suggest, parseSlash, helpText, commandRows, runSlash, assertRegistry,
|
|
492
|
+
};
|
|
493
|
+
});
|