@cubeai/dsh-feishu-channels 0.0.0-stage → 0.2.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/LICENSE +201 -0
- package/NOTICE +15 -0
- package/README.md +56 -2
- package/client.js +4189 -0
- package/cordis.patch.yml +4 -0
- package/docs/coexistence.md +112 -0
- package/docs/dsh-feishu-channels-plan.md +1406 -0
- package/docs/ui-revamp-plan.md +263 -0
- package/docs/verification.md +120 -0
- package/icon.svg +17 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +76 -4
- package/src/channels/connection-state.js +12 -0
- package/src/channels/diagnostics.js +77 -0
- package/src/channels/factory.js +20 -0
- package/src/channels/feishu-app.js +267 -0
- package/src/channels/feishu.js +399 -0
- package/src/channels/media.js +9 -0
- package/src/channels/meta.js +24 -0
- package/src/channels/qr/feishu.js +84 -0
- package/src/channels/qr/hub.js +229 -0
- package/src/channels/qr/render.js +8 -0
- package/src/channels/qr/shared.js +50 -0
- package/src/engine/abort.js +35 -0
- package/src/engine/access.js +23 -0
- package/src/engine/agent-options.js +19 -0
- package/src/engine/approval.js +70 -0
- package/src/engine/assistant-settings.js +38 -0
- package/src/engine/assistant-text.js +11 -0
- package/src/engine/atomic-file.js +45 -0
- package/src/engine/chat-commands.js +752 -0
- package/src/engine/choice-delivery.js +20 -0
- package/src/engine/choices.js +103 -0
- package/src/engine/command-locale.js +70 -0
- package/src/engine/command-messages.js +434 -0
- package/src/engine/command-permissions.js +38 -0
- package/src/engine/command-replies.js +59 -0
- package/src/engine/credentials.js +54 -0
- package/src/engine/deferred-delivery.js +260 -0
- package/src/engine/delivery-history.js +39 -0
- package/src/engine/file-delivery.js +215 -0
- package/src/engine/file-input.js +52 -0
- package/src/engine/file-log.js +25 -0
- package/src/engine/gateway.js +1337 -0
- package/src/engine/image-input.js +31 -0
- package/src/engine/json-state.js +25 -0
- package/src/engine/keyed-queue.js +20 -0
- package/src/engine/merge.js +56 -0
- package/src/engine/message-progress.js +274 -0
- package/src/engine/question.js +201 -0
- package/src/engine/quiet-logger.js +25 -0
- package/src/engine/reply-stream.js +86 -0
- package/src/engine/router.js +713 -0
- package/src/engine/secure-file.js +8 -0
- package/src/engine/seen-store.js +43 -0
- package/src/engine/session-export.js +64 -0
- package/src/engine/session-history.js +30 -0
- package/src/engine/session-id.js +62 -0
- package/src/engine/session-store.js +109 -0
- package/src/engine/session-title.js +29 -0
- package/src/engine/split.js +291 -0
- package/src/engine/workspace-path.js +9 -0
- package/src/index.js +62 -0
- package/src/manager.js +1325 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
import { exportSession } from './session-export.js';
|
|
2
|
+
import { replyText, withReplyLocale, localizeStatus } from './command-locale.js';
|
|
3
|
+
/** IM 对 Chat 的薄适配:控制操作走 Host Controller,斜杠命令走同一注册表。 */
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { commandNavigation, extensionHelp, extensionReply, oneLine, related } from './command-replies.js';
|
|
7
|
+
import { readSessionHistory } from './session-history.js';
|
|
8
|
+
const modelDefaultHint = () => replyText('与 Chat 一致,此操作也会尝试保存后续 Chat 新会话的默认模型选择;已有其他会话不会主动修改。');
|
|
9
|
+
export const chatControlHelp = () => [
|
|
10
|
+
replyText('飞书助手已连接 DeepSeek Harness。直接发送文字即可开始任务。'), replyText('例如:帮我整理今天的待办。'), '',
|
|
11
|
+
replyText('**入口**'),
|
|
12
|
+
replyText('/menu(或 /m)— 打开带按钮的操作菜单,和下面的列表命令等价'),
|
|
13
|
+
replyText('/help — 显示本说明'), '',
|
|
14
|
+
replyText('**会话与工作区**'),
|
|
15
|
+
replyText('/new(或 /clear)— 新开会话;旧会话保留在飞书频道列表'),
|
|
16
|
+
replyText('/sessions — 列出会话,点按钮或回序号接续'),
|
|
17
|
+
replyText('/session 序号或ID — 直接接续指定会话'),
|
|
18
|
+
replyText('/workspaces — 列出工作区,点按钮或回序号在其中新建'),
|
|
19
|
+
replyText('/workspace 序号或路径 — 直接在该工作区新建会话'),
|
|
20
|
+
replyText('/history — 当前会话最近的文字记录'),
|
|
21
|
+
replyText('/rename 新名称 — 给当前会话改名'),
|
|
22
|
+
replyText('/fork — 分叉当前会话'), '',
|
|
23
|
+
replyText('**模型与推理**'),
|
|
24
|
+
replyText('/models — 列出可选模型并选择'),
|
|
25
|
+
replyText('/model — 只看当前模型'),
|
|
26
|
+
replyText('/reasoning — 列出推理等级并选择'),
|
|
27
|
+
replyText('/presets — 列出 Agent 预设并选择'),
|
|
28
|
+
replyText('/preset 序号或ID — 直接用该预设新建会话'), '',
|
|
29
|
+
replyText('**任务与交付**'),
|
|
30
|
+
replyText('/status(或 /current)— 当前状态'),
|
|
31
|
+
replyText('/stop — 请求停止,保留队列'),
|
|
32
|
+
replyText('/steer 补充要求 — 提交补充指令'),
|
|
33
|
+
replyText('/queue — 查看队列和操作方法'),
|
|
34
|
+
replyText('/delivery — 查看交付记录和补发方法'),
|
|
35
|
+
replyText('/unbind [序号|ID|all] — 解除本聊天对旧会话的登记,不再推送其输出'),
|
|
36
|
+
replyText('/mute 序号或ID — 静音指定会话的推送;/unmute 恢复'),
|
|
37
|
+
replyText('/export — 导出当前会话 ZIP 并发送'), '',
|
|
38
|
+
replyText('命令单独发送为文字;图片说明按普通消息处理。'),
|
|
39
|
+
].join('\n');
|
|
40
|
+
export class ChatCommands {
|
|
41
|
+
host;
|
|
42
|
+
router;
|
|
43
|
+
pending;
|
|
44
|
+
onSession;
|
|
45
|
+
showChoices;
|
|
46
|
+
lifetime = new AsyncLocalStorage();
|
|
47
|
+
choices = new Map();
|
|
48
|
+
reasoningChoices = new Map();
|
|
49
|
+
constructor(host, router, pending, onSession = () => { }, showChoices) {
|
|
50
|
+
this.host = host;
|
|
51
|
+
this.router = router;
|
|
52
|
+
this.pending = pending;
|
|
53
|
+
this.onSession = onSession;
|
|
54
|
+
this.showChoices = showChoices;
|
|
55
|
+
}
|
|
56
|
+
clear() { this.choices.clear(); this.reasoningChoices.clear(); }
|
|
57
|
+
service(name) {
|
|
58
|
+
const value = this.host.get(name);
|
|
59
|
+
if (!value)
|
|
60
|
+
throw new Error(replyText('当前 Host 未提供 {0},请升级 DSH。', name));
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
async call(service, method, ...args) {
|
|
64
|
+
this.lifetime.getStore()?.throwIfAborted();
|
|
65
|
+
const target = this.service(service);
|
|
66
|
+
if (typeof target[method] !== 'function')
|
|
67
|
+
throw new Error(replyText('当前 Host 不支持 {0}。', method));
|
|
68
|
+
const value = (await target[method](...args));
|
|
69
|
+
this.lifetime.getStore()?.throwIfAborted();
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
async presets() {
|
|
73
|
+
const listed = await this.call('agentPresets', 'remoteExportList');
|
|
74
|
+
return listed?.presets ?? [];
|
|
75
|
+
}
|
|
76
|
+
permissionLabel(permissions) {
|
|
77
|
+
const named = permissions.options?.find((item) => item.value === permissions.currentValue)?.name;
|
|
78
|
+
if (named)
|
|
79
|
+
return named;
|
|
80
|
+
const presets = this.host.get('permissionPresets');
|
|
81
|
+
try {
|
|
82
|
+
const option = presets?.optionOf?.(permissions.currentValue);
|
|
83
|
+
if (option?.name)
|
|
84
|
+
return option.name;
|
|
85
|
+
}
|
|
86
|
+
catch { /* 未知预设不能让状态查询失败。 */ }
|
|
87
|
+
return permissions.currentValue;
|
|
88
|
+
}
|
|
89
|
+
async agent(sessionId) {
|
|
90
|
+
const result = await this.call('sessionController', 'resolveAgent', sessionId);
|
|
91
|
+
if (!result.agent)
|
|
92
|
+
throw new Error(result.error?.message || replyText('无法打开会话。'));
|
|
93
|
+
return result.agent;
|
|
94
|
+
}
|
|
95
|
+
async snapshot(sessionId, signal) {
|
|
96
|
+
const stream = await this.call('sessionController', 'follow', { address: { kind: 'session', sessionId }, maxMessages: 10 }, signal);
|
|
97
|
+
for await (const frame of stream)
|
|
98
|
+
return frame;
|
|
99
|
+
throw new Error(replyText('无法读取会话快照。'));
|
|
100
|
+
}
|
|
101
|
+
async workspaces(signal) {
|
|
102
|
+
const stream = await this.call('workspaceController', 'follow', signal);
|
|
103
|
+
for await (const frame of stream)
|
|
104
|
+
return { items: frame.value?.items ?? [], archivedSessionIds: frame.value?.archivedSessionIds ?? [] };
|
|
105
|
+
throw new Error(replyText('无法读取工作区。'));
|
|
106
|
+
}
|
|
107
|
+
async listSessions(signal) {
|
|
108
|
+
const listed = await this.call('sessionController', 'list', {}, signal);
|
|
109
|
+
return listed?.items ?? [];
|
|
110
|
+
}
|
|
111
|
+
remember(key, values) {
|
|
112
|
+
if (this.choices.size >= 256)
|
|
113
|
+
this.choices.delete(this.choices.keys().next().value);
|
|
114
|
+
this.choices.set(key, { values, time: Date.now() });
|
|
115
|
+
}
|
|
116
|
+
reasoningChoice(scope, sessionId, selected, effort) {
|
|
117
|
+
if (this.reasoningChoices.size >= 512)
|
|
118
|
+
this.reasoningChoices.delete(this.reasoningChoices.keys().next().value);
|
|
119
|
+
const token = randomUUID();
|
|
120
|
+
this.reasoningChoices.set(token, { scope, sessionId, provider: selected.provider, model: selected.model, effort, time: Date.now() });
|
|
121
|
+
return `--choice ${token}`;
|
|
122
|
+
}
|
|
123
|
+
resolve(key, value) {
|
|
124
|
+
if (!/^\d+$/.test(value))
|
|
125
|
+
return value;
|
|
126
|
+
const choice = this.choices.get(key);
|
|
127
|
+
const selected = choice && Date.now() - choice.time < 15 * 60_000 ? choice.values[Number(value) - 1] : undefined;
|
|
128
|
+
if (!selected) {
|
|
129
|
+
if (key.endsWith(':unbind'))
|
|
130
|
+
throw new Error(replyText('序号无效或已过期,本次操作未执行。请发送 /unbind 获取新列表。'));
|
|
131
|
+
throw new Error(replyText('序号无效或已过期,尚未切换。请发送 /{0} 获取新列表。', key.endsWith(':reasoning') ? 'reasoning' : key.endsWith(':presets') ? 'presets' : key.endsWith(':models') ? 'models' : key.endsWith(':workspaces') ? 'workspaces' : 'sessions'));
|
|
132
|
+
}
|
|
133
|
+
return selected;
|
|
134
|
+
}
|
|
135
|
+
async idle(sessionId, signal, command) {
|
|
136
|
+
if (!sessionId)
|
|
137
|
+
return;
|
|
138
|
+
if (this.pending(sessionId))
|
|
139
|
+
throw new Error(replyText('请先完成当前问题或审批,再执行 /{0}。', command));
|
|
140
|
+
const rows = await this.listSessions(signal);
|
|
141
|
+
if (rows.find((row) => row.sessionId === sessionId)?.running)
|
|
142
|
+
throw new Error(replyText('当前任务正在运行,请先 /stop,等待停止后再执行 /{0}。', command));
|
|
143
|
+
}
|
|
144
|
+
execute(channel, msg, signal) {
|
|
145
|
+
return withReplyLocale(this.host, () => this.lifetime.run(signal, async () => {
|
|
146
|
+
try {
|
|
147
|
+
// 原生渠道直接展示选择页,文字渠道沿用原列表及序号契约。
|
|
148
|
+
const list = /^\/(sessions|sessionlist|workspaces|workspacelist|models|presets|presetlist|reasoning|reasonings|reasoninglist)(?:\s+(\d+))?\s*$/i.exec(msg.text.trim());
|
|
149
|
+
if (list && !(list[1].toLowerCase().startsWith('reasoning') && list[2]) && channel.sendChoices && this.showChoices && !msg.media?.length) {
|
|
150
|
+
const name = list[1].toLowerCase();
|
|
151
|
+
const section = name === 'sessionlist' ? 'sessions' : name === 'workspacelist' ? 'workspaces' : name === 'presetlist' ? 'presets' : name === 'reasonings' || name === 'reasoninglist' ? 'reasoning' : name;
|
|
152
|
+
// 尚无会话时 /models 仍可浏览目录,不把查询变成必须创建会话的操作。
|
|
153
|
+
if (section !== 'models' || this.router.lookup(channel.id, msg.kind ?? 'dm', msg.chatId)) {
|
|
154
|
+
msg = { ...msg, text: `/menu ${section} ${list[2] || '1'}` };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const navigation = {};
|
|
158
|
+
const text = await this.run(channel, msg, signal, navigation);
|
|
159
|
+
if (!text)
|
|
160
|
+
return text;
|
|
161
|
+
const command = /^\/([a-z][a-z0-9_-]*)/i.exec(msg.text.trim())?.[1]?.toLowerCase() ?? '';
|
|
162
|
+
const current = this.router.lookup(channel.id, msg.kind ?? 'dm', msg.chatId);
|
|
163
|
+
const choices = navigation.choices ?? commandNavigation(command, !!current);
|
|
164
|
+
if (!choices.length)
|
|
165
|
+
return text;
|
|
166
|
+
const fallback = text + related(replyText('接下来可以:'), ...choices.map(choice => `${choice.label} — ${choice.value}`));
|
|
167
|
+
// 导航发送失败不能把已完成的命令改报失败;新会话的按钮绑定切换后的会话。
|
|
168
|
+
if (channel.sendChoices && this.showChoices && !signal.aborted) {
|
|
169
|
+
try {
|
|
170
|
+
return await this.showChoices(channel, msg, text, choices, current?.sessionId, false);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return fallback;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return fallback;
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (error?.code === 'im/session-in-use')
|
|
180
|
+
throw new Error(replyText('该会话已关联其他聊天,不能重复接续。请用 /sessions 选择其他会话,或 /new 新建。'));
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
/** 附加查询限时且独立降级,不能把已完成操作改报失败。 */
|
|
186
|
+
async optional(read, parent) {
|
|
187
|
+
const scope = new AbortController();
|
|
188
|
+
const signal = AbortSignal.any([parent, scope.signal]);
|
|
189
|
+
let timer;
|
|
190
|
+
try {
|
|
191
|
+
return await Promise.race([
|
|
192
|
+
Promise.resolve().then(() => this.lifetime.run(signal, () => read(signal))).catch(() => undefined),
|
|
193
|
+
new Promise((resolve) => { timer = setTimeout(() => { scope.abort(); resolve(undefined); }, 800); }),
|
|
194
|
+
]);
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
scope.abort();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async newDetails(binding, signal) {
|
|
202
|
+
let cwd;
|
|
203
|
+
let model;
|
|
204
|
+
try {
|
|
205
|
+
const session = binding.handle?.agent?.session;
|
|
206
|
+
cwd = session?.header?.cwd;
|
|
207
|
+
model = (await readSessionHistory(this.host, session, signal) ?? []).findLast((event) => event.type === 'model/selection')?.data;
|
|
208
|
+
}
|
|
209
|
+
catch { /* 继续尝试宿主快照,不能改用账号默认值冒充实际配置。 */ }
|
|
210
|
+
const [snapshot, rows] = await Promise.all([
|
|
211
|
+
model?.provider && model.model ? undefined : this.optional((sig) => this.snapshot(binding.sessionId, sig), signal),
|
|
212
|
+
cwd ? undefined : this.optional((sig) => this.call('sessionController', 'list', {}, sig), signal),
|
|
213
|
+
]);
|
|
214
|
+
model = model?.provider && model.model ? model : snapshot?.projections?.values?.modelSelection?.next;
|
|
215
|
+
cwd ||= rows?.items?.find((row) => row.sessionId === binding.sessionId)?.cwd;
|
|
216
|
+
return [replyText('工作区:{0}', cwd || replyText('暂时无法读取')), model ? replyText('模型:{0}/{1} · {2}', model.provider, model.model, model.reasoningEffort || replyText('默认推理')) : replyText('模型:暂时无法读取')].join('\n');
|
|
217
|
+
}
|
|
218
|
+
async run(channel, msg, signal, navigation) {
|
|
219
|
+
signal.throwIfAborted();
|
|
220
|
+
const match = /^\/([a-z][a-z0-9_-]*)([\s\S]*)$/i.exec(msg.text.trim());
|
|
221
|
+
if (!match)
|
|
222
|
+
throw new Error(replyText('命令格式无效,请发送 /help。'));
|
|
223
|
+
const command = match[1].toLowerCase();
|
|
224
|
+
const input = match[2].trim();
|
|
225
|
+
const kind = msg.kind === 'group' ? 'group' : 'dm';
|
|
226
|
+
const scope = JSON.stringify([channel.id, kind, msg.chatId, msg.userId ?? '']);
|
|
227
|
+
if (msg.media?.length && ['preset', 'presets', 'presetlist', 'help', 'new', 'clear', 'sessions', 'sessionlist', 'session', 'unbind', 'mute', 'unmute', 'workspaces', 'workspacelist', 'workspace', 'models', 'status', 'current', 'stop', 'steer', 'rename', 'fork', 'model', 'reasoning', 'reasonings', 'reasoninglist', 'history', 'queue', 'export'].includes(command))
|
|
228
|
+
throw new Error(replyText('该控制命令不接受附件,请单独发送。'));
|
|
229
|
+
const current = this.router.lookup(channel.id, kind, msg.chatId);
|
|
230
|
+
const requireCurrent = () => { if (!current)
|
|
231
|
+
throw new Error(replyText('当前没有会话,请先发送消息或 /new。')); return current.sessionId; };
|
|
232
|
+
if (input && ['presets', 'presetlist', 'help', 'new', 'clear', 'workspaces', 'workspacelist', 'models', 'status', 'current', 'stop', 'fork', 'history', 'reasonings', 'reasoninglist', 'export'].includes(command))
|
|
233
|
+
throw new Error(replyText('/{0} 暂不支持参数。正确用法:/{1}', command, command));
|
|
234
|
+
if (command === 'menu' || command === 'm') {
|
|
235
|
+
const parts = input.split(/\s+/);
|
|
236
|
+
const rootPage = /^\d+$/.test(parts[0] ?? '');
|
|
237
|
+
const [section = '', pageText = '1', ...extra] = rootPage ? ['', ...parts] : parts;
|
|
238
|
+
const page = Number(pageText);
|
|
239
|
+
if (extra.length || !['', 'sessions', 'workspaces', 'models', 'presets', 'reasoning'].includes(section) || !Number.isSafeInteger(page) || page < 1)
|
|
240
|
+
throw new Error(replyText('用法:/menu [sessions|workspaces|models|presets|reasoning] [页码]'));
|
|
241
|
+
let choices = [];
|
|
242
|
+
let text = replyText('助手操作菜单');
|
|
243
|
+
if (section === 'sessions') {
|
|
244
|
+
const rows = await this.listSessions(signal);
|
|
245
|
+
const workspaces = await this.workspaces(signal);
|
|
246
|
+
choices = rows.filter(row => !row.origin && !row.running && row.sessionId !== current?.sessionId && !workspaces.archivedSessionIds.includes(row.sessionId)
|
|
247
|
+
&& !this.router.isBoundElsewhere?.(row.sessionId, channel.id, kind, msg.chatId)).map(row => ({ label: oneLine(row.projections?.values?.title || row.sessionId), value: `/session ${row.sessionId}` }));
|
|
248
|
+
}
|
|
249
|
+
else if (section === 'workspaces') {
|
|
250
|
+
choices = (await this.workspaces(signal)).items.map(item => ({ label: oneLine(item.title ? `${item.title} · ${item.path}` : item.path), value: `/workspace ${item.path}` }));
|
|
251
|
+
}
|
|
252
|
+
else if (section === 'reasoning') {
|
|
253
|
+
const sessionId = requireCurrent();
|
|
254
|
+
const catalog = await this.call('sessionController', 'modelCatalog');
|
|
255
|
+
const selected = (await this.snapshot(sessionId, signal)).projections?.values?.modelSelection?.next || catalog.default;
|
|
256
|
+
const model = selected ? catalog.groups?.find(group => group.id === selected.provider)?.models?.find(item => item.id === selected.model) : undefined;
|
|
257
|
+
const efforts = model?.reasoning?.efforts || [];
|
|
258
|
+
text = replyText('模型:{0}\n当前推理:{1}', oneLine(model?.name || selected.model), selected.reasoningEffort || replyText('默认'));
|
|
259
|
+
choices = efforts.map(item => ({ label: oneLine(item.name || item.id) + (item.id === selected.reasoningEffort ? replyText('〔当前〕') : ''), value: `/reasoning ${this.reasoningChoice(scope, sessionId, selected, item.id)}` }));
|
|
260
|
+
if (efforts.length)
|
|
261
|
+
choices.push({ label: replyText('恢复默认推理'), value: `/reasoning ${this.reasoningChoice(scope, sessionId, selected)}` });
|
|
262
|
+
}
|
|
263
|
+
else if (section === 'presets') {
|
|
264
|
+
choices = (await this.presets()).filter(item => !item.broken).map(item => ({ label: oneLine(item.name || item.id) + (item.isDefault ? replyText('〔默认〕') : ''), value: `/preset id:${item.id}` }));
|
|
265
|
+
choices.push({ label: replyText('跟随默认预设'), value: '/preset --default' });
|
|
266
|
+
}
|
|
267
|
+
else if (section === 'models') {
|
|
268
|
+
requireCurrent();
|
|
269
|
+
const catalog = await this.call('sessionController', 'modelCatalog');
|
|
270
|
+
choices = (catalog?.groups ?? []).flatMap(group => (group.models ?? []).map(model => ({ label: oneLine(`${model.name} (${group.id})`), value: `/model ${group.id}/${model.id}` })));
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
if (current)
|
|
274
|
+
text += '\n' + await this.newDetails(current, signal);
|
|
275
|
+
choices = [
|
|
276
|
+
{ label: replyText('新会话'), value: '/new' }, { label: replyText('选择会话'), value: '/menu sessions' },
|
|
277
|
+
{ label: replyText('选择工作区'), value: '/menu workspaces' },
|
|
278
|
+
...(current ? [{ label: replyText('选择模型'), value: '/menu models' }, { label: replyText('停止任务'), value: '/stop' },
|
|
279
|
+
{ label: replyText('导出会话'), value: '/export' }, { label: replyText('查看状态'), value: '/status' }] : []),
|
|
280
|
+
{ label: replyText('选择预设'), value: '/menu presets' },
|
|
281
|
+
{ label: replyText('帮助'), value: '/help' },
|
|
282
|
+
];
|
|
283
|
+
}
|
|
284
|
+
{
|
|
285
|
+
// 按每页实际需要的导航按钮计算容量,首页不预留不存在的上一页。
|
|
286
|
+
const limit = channel.choiceLimits?.maxButtons ?? 11;
|
|
287
|
+
const slices = [];
|
|
288
|
+
let offset = 0;
|
|
289
|
+
do {
|
|
290
|
+
const back = section ? 1 : slices.length ? 1 : 0;
|
|
291
|
+
const previous = slices.length ? 1 : 0;
|
|
292
|
+
const remaining = choices.length - offset;
|
|
293
|
+
const capacity = Math.max(1, limit - back - previous);
|
|
294
|
+
const size = remaining <= capacity ? capacity : Math.max(1, capacity - 1);
|
|
295
|
+
slices.push(choices.slice(offset, offset + size));
|
|
296
|
+
offset += size;
|
|
297
|
+
} while (offset < choices.length);
|
|
298
|
+
const pages = slices.length;
|
|
299
|
+
if (page > pages)
|
|
300
|
+
throw new Error(replyText('没有第 {0} 页,共 {1} 页。', page, pages));
|
|
301
|
+
const heading = section === 'sessions' ? replyText('选择会话') : section === 'workspaces' ? replyText('选择工作区') : section === 'reasoning' ? text : section === 'presets' ? replyText('选择预设') : section === 'models' ? replyText('选择模型') : replyText('助手操作菜单');
|
|
302
|
+
// 部分平台正文空间有限,菜单展示导航,配置详情通过 /status 查询。
|
|
303
|
+
text = channel.choiceLimits || section ? heading : text;
|
|
304
|
+
text += ` · ${page}/${pages}`;
|
|
305
|
+
if (section === 'presets')
|
|
306
|
+
text += '\n' + replyText('选择后在当前工作区新建并切换会话,旧会话保留。账号默认预设不变。');
|
|
307
|
+
if (!choices.length)
|
|
308
|
+
text += '\n' + replyText('暂无可选项,可返回菜单选择其他操作。');
|
|
309
|
+
choices = slices[page - 1];
|
|
310
|
+
if (section === 'reasoning')
|
|
311
|
+
choices = choices.map((choice, i) => ({ ...choice, displayValue: `/reasoning ${i + 1}` }));
|
|
312
|
+
if (section)
|
|
313
|
+
this.remember(`${scope}:${section}`, choices.map(choice => choice.value.slice(choice.value.indexOf(' ') + 1)));
|
|
314
|
+
const pageCommand = section ? `/menu ${section}` : '/menu';
|
|
315
|
+
if (page > 1)
|
|
316
|
+
choices.push({ label: replyText('上一页'), value: `${pageCommand} ${page - 1}` });
|
|
317
|
+
if (page < pages)
|
|
318
|
+
choices.push({ label: replyText('下一页'), value: `${pageCommand} ${page + 1}` });
|
|
319
|
+
if (current && pages === 1 && choices.length + 3 <= limit && ['models', 'presets', 'reasoning'].includes(section)) {
|
|
320
|
+
choices.push(...commandNavigation(section, true).filter(choice => choice.value !== '/menu' && choice.value !== `/menu ${section}`));
|
|
321
|
+
}
|
|
322
|
+
if (section || page > 1)
|
|
323
|
+
choices.push({ label: replyText('返回菜单'), value: '/menu' });
|
|
324
|
+
}
|
|
325
|
+
signal.throwIfAborted();
|
|
326
|
+
if (!this.showChoices)
|
|
327
|
+
return text + '\n' + choices.map(choice => `${choice.label}:${choice.displayValue ?? choice.value}`).join('\n');
|
|
328
|
+
return this.showChoices(channel, msg, text, choices, current?.sessionId);
|
|
329
|
+
}
|
|
330
|
+
if (command === 'export') {
|
|
331
|
+
const sessionId = requireCurrent();
|
|
332
|
+
return await exportSession(this.host, channel, msg.chatId, sessionId, signal, () => this.router.lookup(channel.id, kind, msg.chatId)?.sessionId === sessionId);
|
|
333
|
+
}
|
|
334
|
+
if (command === 'help') {
|
|
335
|
+
let commands = [];
|
|
336
|
+
let hint = current ? '' : replyText('\n发送消息创建会话后,可查看该会话的 Chat 命令。');
|
|
337
|
+
try {
|
|
338
|
+
if (current && this.host.get('commands') && this.host.get('sessionController'))
|
|
339
|
+
commands = await this.call('commands', 'list', await this.agent(current.sessionId));
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
signal.throwIfAborted();
|
|
343
|
+
hint = replyText('\n扩展命令暂时无法读取,以上内置帮助仍可使用;稍后重试 /help。');
|
|
344
|
+
}
|
|
345
|
+
const descriptions = extensionHelp();
|
|
346
|
+
const help = chatControlHelp();
|
|
347
|
+
// 基础帮助里已经列过的命令不再在扩展小节重复出现(例如 /export)。
|
|
348
|
+
const documented = new Set([...help.matchAll(/\/([a-z][a-z0-9_-]*)/gi)].map((match) => match[1].toLowerCase()));
|
|
349
|
+
const extras = commands.filter((item) => !documented.has(item.name.toLowerCase()));
|
|
350
|
+
return `${oneLine(channel.label || replyText('机器人'))}\n` + help
|
|
351
|
+
+ (extras.length ? replyText('\n\n扩展命令:\n') + extras.map((item) => `/${item.name} — ${descriptions[item.name] || oneLine(item.description)}`).join('\n') : '') + hint;
|
|
352
|
+
}
|
|
353
|
+
if (command === 'new' || command === 'clear') {
|
|
354
|
+
if (current && this.host.get('sessionController'))
|
|
355
|
+
await this.idle(current.sessionId, signal, command);
|
|
356
|
+
const next = await this.router.rotate(channel.id, kind, msg.chatId, msg.username || msg.chatId, { signal });
|
|
357
|
+
return [replyText('已开启新会话'), await this.newDetails(next, signal), replyText('旧会话已保留,直接发送消息即可开始。')].filter(Boolean).join('\n')
|
|
358
|
+
+ related(replyText('查看旧会话:/sessions'), replyText('查看当前配置:/status'));
|
|
359
|
+
}
|
|
360
|
+
if (command === 'sessions' || command === 'sessionlist') {
|
|
361
|
+
const page = input ? Number(input) : 1;
|
|
362
|
+
if (!Number.isInteger(page) || page < 1)
|
|
363
|
+
throw new Error(replyText('用法:/sessions [页码]'));
|
|
364
|
+
const rows = (await this.listSessions(signal)).filter((row) => !row.origin);
|
|
365
|
+
this.remember(scope + ':sessions', rows.map((row) => row.sessionId));
|
|
366
|
+
if (!rows.length)
|
|
367
|
+
return replyText('暂无可接续会话。') + related(replyText('直接发送消息,或 /new 开始聊天'));
|
|
368
|
+
const pages = Math.ceil(rows.length / 10);
|
|
369
|
+
if (page > pages)
|
|
370
|
+
return replyText('没有第 {0} 页,共 {1} 页。', page, pages) + related(replyText('返回列表:/sessions {0}', pages));
|
|
371
|
+
const visible = rows.slice((page - 1) * 10, page * 10);
|
|
372
|
+
const workspace = await this.optional((sig) => this.workspaces(sig), signal);
|
|
373
|
+
signal.throwIfAborted();
|
|
374
|
+
const archived = workspace ? new Set(workspace.archivedSessionIds) : undefined;
|
|
375
|
+
const occupied = (row) => this.router.isBoundElsewhere?.(row.sessionId, channel.id, kind, msg.chatId) === true;
|
|
376
|
+
const available = visible.find((row) => row.sessionId !== current?.sessionId && !row.running && !occupied(row) && archived && !archived.has(row.sessionId));
|
|
377
|
+
return replyText('会话列表 · 第 {0}/{1} 页 · 共 {2} 个\n\n', page, pages, rows.length)
|
|
378
|
+
+ visible.map((row, i) => `${(page - 1) * 10 + i + 1}. ${oneLine(row.projections?.values?.title || replyText('未命名会话'))}${row.sessionId === current?.sessionId ? replyText('〔当前〕') : ''}${archived?.has(row.sessionId) ? replyText('〔已归档,需先在网页恢复〕') : occupied(row) ? replyText('〔已关联其他聊天〕') : row.running ? replyText('〔运行中〕') : ''}\n${row.sessionId}`).join('\n\n')
|
|
379
|
+
+ related(available ? replyText('切换:/session {0}', rows.indexOf(available) + 1) : '', !archived ? replyText('归档状态暂时无法读取,切换时会重新校验。') : '', page < pages ? replyText('下一页:/sessions {0}', page + 1) : '', replyText('序号 15 分钟内有效,也可使用完整会话 ID。'));
|
|
380
|
+
}
|
|
381
|
+
if (command === 'session') {
|
|
382
|
+
if (!input) {
|
|
383
|
+
if (!current)
|
|
384
|
+
return replyText('当前没有会话。直接发送消息或 /new 开始。');
|
|
385
|
+
const rows = await this.listSessions(signal);
|
|
386
|
+
const row = rows.find((item) => item.sessionId === current.sessionId);
|
|
387
|
+
return replyText('当前会话:{0}\n工作区:{1}\n会话 ID:{2}', oneLine(row?.projections?.values?.title || replyText('未命名会话')), row?.cwd || replyText('暂时无法读取'), current.sessionId) + related(replyText('切换会话:/sessions'), replyText('详细状态:/status'));
|
|
388
|
+
}
|
|
389
|
+
const id = this.resolve(scope + ':sessions', input);
|
|
390
|
+
const rows = await this.listSessions(signal);
|
|
391
|
+
const row = rows.find((item) => item.sessionId === id && !item.origin);
|
|
392
|
+
if (!row)
|
|
393
|
+
throw new Error(replyText('会话不存在或不是可接入的普通会话。'));
|
|
394
|
+
const workspace = await this.workspaces(signal);
|
|
395
|
+
if (workspace.archivedSessionIds.includes(id))
|
|
396
|
+
throw new Error(replyText('请先在 Chat 恢复该归档会话。'));
|
|
397
|
+
if (workspace.items.filter((item) => item.sessionIds?.includes(id)).length !== 1)
|
|
398
|
+
throw new Error(replyText('会话工作区归属无效。'));
|
|
399
|
+
if (row.running && id !== current?.sessionId)
|
|
400
|
+
throw new Error(replyText('目标会话正在运行,请在 Chat 停止后再切换。'));
|
|
401
|
+
await this.idle(current?.sessionId, signal, command);
|
|
402
|
+
await this.router.bind(channel.id, kind, msg.chatId, id, row.projections?.values?.title || id, await this.agent(id), row.cwd || workspace.items.find((item) => item.sessionIds?.includes(id))?.path);
|
|
403
|
+
return replyText('已切换会话:{0}\n工作区:{1}\n接下来的消息会发送到此会话。', oneLine(row.projections?.values?.title || id), row.cwd || workspace.items.find((item) => item.sessionIds?.includes(id))?.path || replyText('暂时无法读取')) + related(replyText('最近记录:/history'), replyText('重新开始:/new'));
|
|
404
|
+
}
|
|
405
|
+
if (command === 'unbind' || command === 'mute' || command === 'unmute') {
|
|
406
|
+
if (command !== 'unbind' && !input)
|
|
407
|
+
throw new Error(replyText('用法:/{0} 序号或ID。先发送 /unbind 查看本聊天登记的会话。', command));
|
|
408
|
+
const records = this.router.chatRecords(channel.id, kind, msg.chatId);
|
|
409
|
+
if (command === 'unbind' && !input) {
|
|
410
|
+
this.remember(scope + ':unbind', records.map((item) => item.sessionId));
|
|
411
|
+
if (!records.length)
|
|
412
|
+
return replyText('本聊天没有登记任何会话。');
|
|
413
|
+
return replyText('本聊天登记的会话 · 共 {0} 个\n\n', records.length)
|
|
414
|
+
+ records.map((item, i) => `${i + 1}. ${oneLine(item.title || '未命名会话')}${item.sessionId === current?.sessionId ? replyText('〔当前〕') : ''}${item.muted ? replyText('〔已静音〕') : ''}\n${item.sessionId}`).join('\n\n')
|
|
415
|
+
+ related(replyText('解除登记:/unbind 序号或ID;全部解除(当前会话除外):/unbind all'), replyText('保留登记但暂停推送:/mute 序号或ID;恢复:/unmute 序号或ID'));
|
|
416
|
+
}
|
|
417
|
+
if (!records.length)
|
|
418
|
+
throw new Error(replyText('本聊天没有登记任何会话。'));
|
|
419
|
+
const target = input.toLowerCase() === 'all' ? 'all' : this.resolve(scope + ':unbind', input);
|
|
420
|
+
const label = (id) => {
|
|
421
|
+
const title = records.find(item => item.sessionId === id)?.title;
|
|
422
|
+
return title && title !== id ? `${oneLine(title)} (${oneLine(id)})` : oneLine(id);
|
|
423
|
+
};
|
|
424
|
+
if (command === 'unbind') {
|
|
425
|
+
if (target === 'all') {
|
|
426
|
+
let removed = 0;
|
|
427
|
+
for (const item of records) {
|
|
428
|
+
if (item.sessionId === current?.sessionId)
|
|
429
|
+
continue;
|
|
430
|
+
if (this.router.unbindSession(channel.id, kind, msg.chatId, item.sessionId))
|
|
431
|
+
removed += 1;
|
|
432
|
+
}
|
|
433
|
+
return replyText(removed ? '已解除 {0} 个会话的登记,它们不再向本聊天推送输出。' : '没有需要解除的登记。', removed) + related(current ? replyText('当前会话未受影响。') : '');
|
|
434
|
+
}
|
|
435
|
+
if (target === current?.sessionId)
|
|
436
|
+
throw new Error(replyText('不能解除当前会话;请先 /new 或 /session 切换。'));
|
|
437
|
+
if (!this.router.unbindSession(channel.id, kind, msg.chatId, target))
|
|
438
|
+
throw new Error(replyText('本聊天没有登记该会话,请发送 /unbind 查看。'));
|
|
439
|
+
return replyText('已解除登记:{0}\n该会话不再向本聊天推送输出;历史仍保留在网页 Chat 中。', label(target)) + related(replyText('查看登记:/unbind'));
|
|
440
|
+
}
|
|
441
|
+
const sessionId = this.resolve(scope + ':unbind', input);
|
|
442
|
+
if (!records.some((item) => item.sessionId === sessionId))
|
|
443
|
+
throw new Error(replyText('本聊天没有登记该会话,请发送 /unbind 查看。'));
|
|
444
|
+
if (!this.router.setMuted(sessionId, command === 'mute'))
|
|
445
|
+
throw new Error(replyText('会话登记异常,请发送 /unbind 查看。'));
|
|
446
|
+
return command === 'mute'
|
|
447
|
+
? replyText('已静音:{0}\n该会话的输出不再推送到本聊天;/unmute {1} 恢复。', label(sessionId), input)
|
|
448
|
+
: replyText('已恢复推送:{0}', label(sessionId)) + related(replyText('静音:/mute {0}', input));
|
|
449
|
+
}
|
|
450
|
+
if (command === 'workspaces' || command === 'workspacelist' || command === 'workspace') {
|
|
451
|
+
const { items } = await this.workspaces(signal);
|
|
452
|
+
if (command !== 'workspace' || !input) {
|
|
453
|
+
this.remember(scope + ':workspaces', items.map((item) => item.workspaceId));
|
|
454
|
+
const alternative = items.findIndex((item) => !current || !item.sessionIds?.includes(current.sessionId));
|
|
455
|
+
return items.length ? replyText('工作区列表 · 共 {0} 个\n\n', items.length) + items.map((item, i) => `${i + 1}. ${item.title ? oneLine(item.title) : oneLine(item.path)}${item.title ? `\n${item.path}` : ''}${current && item.sessionIds?.includes(current.sessionId) ? replyText('〔当前〕') : ''}`).join('\n') + related(alternative >= 0
|
|
456
|
+
? replyText('新建并切换:/workspace {0}', alternative + 1)
|
|
457
|
+
: replyText('当前只有这个工作区。直接发消息继续,或 /new 开启新会话。'), replyText('也可使用已列出的绝对路径;旧会话保留,账号默认目录不变。'), replyText('别名:/workspacelist;序号 15 分钟内有效。')) : replyText('还没有可用工作区。请先在网页添加,再发送 /workspaces。');
|
|
458
|
+
}
|
|
459
|
+
const target = this.resolve(scope + ':workspaces', input);
|
|
460
|
+
const workspace = items.find((item) => item.workspaceId === target || item.path === target);
|
|
461
|
+
if (!workspace)
|
|
462
|
+
throw new Error(replyText('没有找到这个已添加的工作区,尚未切换。发送 /workspaces 查看可选项;新目录请先在网页添加。'));
|
|
463
|
+
await this.idle(current?.sessionId, signal, command);
|
|
464
|
+
await this.router.rotate(channel.id, kind, msg.chatId, msg.username || msg.chatId, { cwd: workspace.path, signal });
|
|
465
|
+
return replyText('已在「{0}」开启新会话。\n工作区:{1}\n旧会话已保留,可以直接发送任务。', oneLine(workspace.title || workspace.path), workspace.path) + related(replyText('查看配置:/status'), replyText('切回旧会话:/sessions'));
|
|
466
|
+
}
|
|
467
|
+
if (command === 'models') {
|
|
468
|
+
const catalog = await this.call('sessionController', 'modelCatalog');
|
|
469
|
+
const models = (catalog?.groups ?? []).flatMap((group) => (group.models ?? []).map((model) => ({ id: `${group.id}/${model.id}`, name: model.name })));
|
|
470
|
+
let selected;
|
|
471
|
+
if (current) {
|
|
472
|
+
try {
|
|
473
|
+
selected = (await this.snapshot(current.sessionId, signal)).projections?.values?.modelSelection?.next;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
signal.throwIfAborted();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
this.remember(scope + ':models', models.map((model) => model.id));
|
|
480
|
+
const alternative = selected
|
|
481
|
+
? models.findIndex((model) => model.id !== `${selected.provider}/${selected.model}`)
|
|
482
|
+
: -1;
|
|
483
|
+
return models.length ? replyText('可用模型 · 共 {0} 个\n\n', models.length) + models.map((model, i) => `${i + 1}. ${oneLine(model.name)}${model.id === (selected && `${selected.provider}/${selected.model}`) ? replyText('〔当前〕') : ''}\n${model.id}`).join('\n\n') + related(current && alternative >= 0
|
|
484
|
+
? replyText('切换:/model {0}', alternative + 1) : current && selected
|
|
485
|
+
? replyText('当前没有其他可切换模型,继续发送消息即可。')
|
|
486
|
+
: '', current ? replyText('查看当前模型:/model;推理选项:/reasoning') : replyText('先发消息或 /new 创建会话,再切换模型。'), replyText('序号 15 分钟内有效。')) : replyText('暂无可用模型。请在网页配置模型服务,再发送 /models。');
|
|
487
|
+
}
|
|
488
|
+
if (command === 'presets' || command === 'presetlist' || command === 'preset') {
|
|
489
|
+
const presets = await this.presets();
|
|
490
|
+
const hint = replyText('选择后在当前工作区新建并切换会话,旧会话保留。账号默认预设不变。');
|
|
491
|
+
if (command !== 'preset') {
|
|
492
|
+
this.remember(scope + ':presets', presets.map(item => `id:${item.id}`));
|
|
493
|
+
return replyText('Agent 预设') + '\n' + presets.map((item, i) => `${i + 1}. ${oneLine(item.name || item.id)} (${item.id})${item.isDefault ? replyText('〔默认〕') : ''}${item.broken ? replyText('〔不可用〕') : ''}${item.description ? '\n' + oneLine(item.description) : ''}`).join('\n\n') + related(hint, replyText('用法:/preset 序号或ID;纯数字 ID:/preset id:ID;跟随默认:/preset --default'));
|
|
494
|
+
}
|
|
495
|
+
const sessionId = requireCurrent();
|
|
496
|
+
if (!input) {
|
|
497
|
+
const id = (await this.snapshot(sessionId, signal)).projections?.values?.agentPreset;
|
|
498
|
+
return replyText('当前 Agent 预设:{0}', id ? oneLine(presets.find(item => item.id === id)?.name || id) : replyText('暂时无法读取')) + related(hint, replyText('用法:/preset 序号或ID;纯数字 ID:/preset id:ID;跟随默认:/preset --default'));
|
|
499
|
+
}
|
|
500
|
+
const resolved = this.resolve(scope + ':presets', input);
|
|
501
|
+
const id = resolved.startsWith('id:') ? resolved.slice(3) : resolved;
|
|
502
|
+
const followDefault = resolved === '--default';
|
|
503
|
+
const preset = followDefault ? presets.find(item => item.isDefault) : presets.find(item => item.id === id);
|
|
504
|
+
if (!preset || preset.broken)
|
|
505
|
+
throw new Error(replyText('预设不存在或不可用,请发送 /presets 重新选择。'));
|
|
506
|
+
await this.idle(sessionId, signal, 'preset');
|
|
507
|
+
const matches = (await this.workspaces(signal)).items.filter(item => item.sessionIds?.includes(sessionId));
|
|
508
|
+
if (matches.length !== 1)
|
|
509
|
+
throw new Error(replyText('无法确定当前工作区,请先用 /workspace 选择工作区。'));
|
|
510
|
+
const workspace = matches[0];
|
|
511
|
+
let createdId;
|
|
512
|
+
try {
|
|
513
|
+
const controller = this.service('sessionController');
|
|
514
|
+
if (typeof controller.create !== 'function')
|
|
515
|
+
throw new Error(replyText('当前 Host 不支持 {0}。', 'create'));
|
|
516
|
+
signal.throwIfAborted();
|
|
517
|
+
const result = await controller.create({ workspaceId: workspace.workspaceId, ...(followDefault ? {} : { agentPreset: id }) });
|
|
518
|
+
createdId = result.sessionId;
|
|
519
|
+
signal.throwIfAborted();
|
|
520
|
+
await this.router.bind(channel.id, kind, msg.chatId, createdId, createdId, await this.agent(createdId), workspace.path, signal);
|
|
521
|
+
return replyText('已使用预设 {0} 新建并切换会话:{1}', result.agentPreset || preset.id, createdId) + '\n' + await this.newDetails({ sessionId: createdId }, signal) + related(hint);
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
const partial = error;
|
|
525
|
+
if (!createdId && partial.code === 'session/workspace-attach-failed')
|
|
526
|
+
createdId = partial.details?.sessionId;
|
|
527
|
+
if (!createdId)
|
|
528
|
+
throw error;
|
|
529
|
+
return replyText('新会话已创建:{0},但未能接续。请在网页检查工作区后发送 /session {0};原聊天绑定保留。', createdId);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// Host 注册命令可直接用于首次会话,控制命令则要求已有目标。
|
|
533
|
+
const controls = ['status', 'current', 'stop', 'steer', 'rename', 'fork', 'model', 'reasoning', 'reasonings', 'reasoninglist', 'history', 'queue'];
|
|
534
|
+
const sessionId = current ? current.sessionId : controls.includes(command) ? requireCurrent() : (await this.router.getOrCreate(channel.id, kind, msg.chatId, msg.username || msg.chatId)).sessionId;
|
|
535
|
+
if (command === 'queue') {
|
|
536
|
+
if (!input) {
|
|
537
|
+
const stream = await this.call('sessionController', 'control', signal);
|
|
538
|
+
for await (const frame of stream) {
|
|
539
|
+
const items = frame.value.queues[sessionId] || [];
|
|
540
|
+
if (!items.length)
|
|
541
|
+
return replyText('当前没有排队消息。') + related(replyText('直接发消息继续'), replyText('查看状态:/status'));
|
|
542
|
+
return replyText('排队消息 · 共 {0} 条\n\n', items.length) + items.map((item) => replyText('{0}〔{1}〕\n{2}', item.id, item.placement === 'queued' ? replyText('等待执行') : item.placement === 'steer' ? replyText('补充指令') : replyText('排队中'), oneLine((item.message?.content ?? []).filter((part) => part.type === 'text').map((part) => part.text || '').join('\n')) || replyText('附件消息'))).join('\n\n')
|
|
543
|
+
+ related(replyText('移除:/queue remove {0}', items[0].id), replyText('修改:/queue edit {0} 新内容', items[0].id), replyText('改为补充指令:/queue steer {0}', items[0].id));
|
|
544
|
+
}
|
|
545
|
+
throw new Error(replyText('无法读取队列。'));
|
|
546
|
+
}
|
|
547
|
+
const action = /^(remove|steer|edit)\s+(\S+)(?:\s+([\s\S]+))?$/.exec(input);
|
|
548
|
+
if (!action || (action[1] === 'edit' && !action[3]) || (action[1] !== 'edit' && action[3]))
|
|
549
|
+
throw new Error(replyText('用法:/queue remove|steer|edit <消息ID> [新内容]'));
|
|
550
|
+
await this.call('sessionController', 'updateQueue', { sessionId, itemId: action[2], action: action[1] === 'edit' ? { kind: 'edit', content: [{ type: 'text', text: action[3] }] } : { kind: action[1] } });
|
|
551
|
+
return replyText('{0}:{1}{2}', action[1] === 'remove' ? replyText('已移除排队消息') : action[1] === 'edit' ? replyText('已修改排队消息') : replyText('已将排队消息调整为补充指令'), action[2], action[1] === 'edit' ? replyText('\n新内容:') + oneLine(action[3]) : '') + related(replyText('查看队列:/queue'), replyText('查看状态:/status'));
|
|
552
|
+
}
|
|
553
|
+
if (command === 'status' || command === 'current') {
|
|
554
|
+
const results = await Promise.allSettled([
|
|
555
|
+
this.call('sessionController', 'list', {}, signal),
|
|
556
|
+
this.snapshot(sessionId, signal),
|
|
557
|
+
]);
|
|
558
|
+
signal.throwIfAborted();
|
|
559
|
+
const row = results[0].status === 'fulfilled' ? results[0].value.items?.find((item) => item.sessionId === sessionId) : undefined;
|
|
560
|
+
const values = results[1].status === 'fulfilled' ? results[1].value.projections?.values : undefined;
|
|
561
|
+
const model = values?.modelSelection?.next;
|
|
562
|
+
const details = [];
|
|
563
|
+
let queueCount;
|
|
564
|
+
if (values?.agentPreset)
|
|
565
|
+
details.push(replyText('Agent 预设:{0}', oneLine(values.agentPreset)));
|
|
566
|
+
if (values?.permissions?.currentValue)
|
|
567
|
+
details.push(replyText('权限:{0}', oneLine(this.permissionLabel(values.permissions))));
|
|
568
|
+
if (values && 'goal' in values) {
|
|
569
|
+
const phases = { active: replyText('活跃'), paused: replyText('已暂停'), blocked: replyText('受阻'), complete: replyText('已完成') };
|
|
570
|
+
details.push(replyText('目标:{0}', values.goal ? phases[values.goal.goal?.phase] || replyText('暂时无法读取') : replyText('无目标')));
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
const stream = await this.call('sessionController', 'control', signal);
|
|
574
|
+
for await (const frame of stream) {
|
|
575
|
+
queueCount = frame.value.queues[sessionId]?.length || 0;
|
|
576
|
+
details.push(replyText('排队消息:{0} 条', queueCount));
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
signal.throwIfAborted();
|
|
582
|
+
}
|
|
583
|
+
const actions = [];
|
|
584
|
+
const phase = values?.goal?.goal?.phase;
|
|
585
|
+
if (phase === 'active' || phase === 'paused' || phase === 'blocked') {
|
|
586
|
+
const registered = await this.optional(async () => this.call('commands', 'list', await this.agent(sessionId)), signal);
|
|
587
|
+
if (registered?.some((item) => item.name === 'goal'))
|
|
588
|
+
actions.push(phase === 'active'
|
|
589
|
+
? { label: replyText('暂停目标'), value: '/goal pause' }
|
|
590
|
+
: { label: replyText('恢复目标'), value: '/goal resume' });
|
|
591
|
+
}
|
|
592
|
+
if (row?.running === true)
|
|
593
|
+
actions.push({ label: replyText('停止任务'), value: '/stop' });
|
|
594
|
+
if (queueCount)
|
|
595
|
+
actions.push({ label: replyText('查看队列'), value: '/queue' });
|
|
596
|
+
if (!actions.length)
|
|
597
|
+
actions.push({ label: replyText('刷新状态'), value: '/status' }, { label: replyText('最近记录'), value: '/history' });
|
|
598
|
+
navigation.choices = [...actions.slice(0, 3), { label: replyText('返回菜单'), value: '/menu' }];
|
|
599
|
+
return [replyText('会话:{0}', oneLine(row?.projections?.values?.title || sessionId)),
|
|
600
|
+
replyText('状态:{0}', row?.running === true ? replyText('运行中') : row?.running === false ? replyText('空闲') : replyText('暂时无法读取')),
|
|
601
|
+
replyText('工作区:{0}', row?.cwd || replyText('暂时无法读取')),
|
|
602
|
+
model ? replyText('模型:{0}/{1};推理:{2}', model.provider, model.model, model.reasoningEffort || replyText('默认')) : replyText('模型:暂时无法读取'),
|
|
603
|
+
...details, replyText('账号:{0}({1})', channel.label, localizeStatus(this.host, channel.status())), replyText('会话 ID:{0}', sessionId),
|
|
604
|
+
].join('\n') + related(...(row?.running === true ? [replyText('补充要求:/steer 补充内容')] : []), ...actions.slice(0, 3).map(action => replyText('{0}:{1}', action.label, action.value)));
|
|
605
|
+
}
|
|
606
|
+
if (command === 'stop') {
|
|
607
|
+
try {
|
|
608
|
+
await this.call('sessionController', 'cancel', { sessionId });
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
if (error?.code !== 'session/not-found')
|
|
612
|
+
throw error;
|
|
613
|
+
// 冷会话没有可取消的 Agent;只接受列表明确确认空闲,不为停止而激活它。
|
|
614
|
+
const state = await this.optional((sig) => this.call('sessionController', 'list', {}, sig), signal);
|
|
615
|
+
if (state?.items?.find(row => row.sessionId === sessionId)?.running !== false)
|
|
616
|
+
throw new Error(replyText('暂时无法确认当前会话的运行状态,未能请求停止。请用 /status 查看,或到网页检查会话。'));
|
|
617
|
+
return replyText('当前没有正在执行的任务。') + related(replyText('确认运行状态:/status'));
|
|
618
|
+
}
|
|
619
|
+
const after = await this.optional((sig) => this.call('sessionController', 'list', {}, sig), signal);
|
|
620
|
+
const idleNow = after?.items?.find((row) => row.sessionId === sessionId)?.running === false;
|
|
621
|
+
let goalHint = '';
|
|
622
|
+
try {
|
|
623
|
+
const goals = this.host.get('goals');
|
|
624
|
+
if (goals) {
|
|
625
|
+
const state = await this.optional(async () => ({ goal: goals.get(await this.agent(sessionId)) }), signal);
|
|
626
|
+
goalHint = !state || (state.goal && !state.goal.phase)
|
|
627
|
+
? replyText('如已启用目标任务,请用 /goal pause 暂停目标。')
|
|
628
|
+
: state.goal?.phase === 'active' ? replyText('当前目标仍处于活跃状态,请用 /goal pause 暂停目标。') : '';
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
catch { /* 停止已提交,附加状态查询失败不能把停止报告为失败。 */ }
|
|
632
|
+
return [idleNow ? replyText('已请求停止。当前没有正在执行的任务;排队消息保留,可用 /queue 查看。') : replyText('已请求停止当前运行;排队消息保留,可用 /queue 查看。'), goalHint].filter(Boolean).join('\n') + related(replyText('确认运行状态:/status'));
|
|
633
|
+
}
|
|
634
|
+
if (command === 'steer') {
|
|
635
|
+
if (!input)
|
|
636
|
+
throw new Error(replyText('请填写补充要求。例如:/steer 只修改登录页面,保留现有配色\n查看状态:/status'));
|
|
637
|
+
this.onSession(sessionId, msg);
|
|
638
|
+
await this.call('sessionController', 'prompt', { sessionId, requestId: randomUUID(), mode: 'steer', content: [{ type: 'text', text: input }] }, signal);
|
|
639
|
+
return replyText('补充指令已提交。\n{0}', oneLine(input)) + related(replyText('查看状态:/status'), replyText('请求停止:/stop'));
|
|
640
|
+
}
|
|
641
|
+
if (command === 'rename') {
|
|
642
|
+
if (!input)
|
|
643
|
+
throw new Error(replyText('请填写新名称,例如:/rename 九月出行计划'));
|
|
644
|
+
const before = await this.optional((sig) => this.call('sessionController', 'list', {}, sig), signal);
|
|
645
|
+
const oldTitle = before?.items?.find((row) => row.sessionId === sessionId)?.projections?.values?.title;
|
|
646
|
+
const result = await this.call('sessionController', 'rename', { sessionId, title: input });
|
|
647
|
+
this.router.rename(sessionId, result.title);
|
|
648
|
+
// 显式改名:顺带把话题名也改掉,行为才符合直觉。
|
|
649
|
+
if (oldTitle === result.title)
|
|
650
|
+
return replyText('当前名称已是:{0}', oneLine(result.title)) +
|
|
651
|
+
related(replyText('查看会话列表:/sessions'));
|
|
652
|
+
return replyText('当前会话已改名:{0}', oldTitle ? `${oneLine(oldTitle)} → ${oneLine(result.title)}` : oneLine(result.title)) + related(replyText('查看会话列表:/sessions'));
|
|
653
|
+
}
|
|
654
|
+
if (command === 'fork') {
|
|
655
|
+
await this.idle(sessionId, signal, command);
|
|
656
|
+
const workspace = (await this.workspaces(signal)).items.find((item) => item.sessionIds?.includes(sessionId));
|
|
657
|
+
if (!workspace)
|
|
658
|
+
throw new Error(replyText('当前会话工作区不可用,无法分叉。'));
|
|
659
|
+
let createdId;
|
|
660
|
+
try {
|
|
661
|
+
signal.throwIfAborted();
|
|
662
|
+
// 必须先保留创建结果,再检查取消,避免已创建的分叉失去可追踪 ID。
|
|
663
|
+
const controller = this.service('sessionController');
|
|
664
|
+
if (typeof controller.fork !== 'function')
|
|
665
|
+
throw new Error(replyText('当前 Host 不支持 fork。'));
|
|
666
|
+
const result = (await controller.fork({ sessionId }));
|
|
667
|
+
createdId = result.sessionId;
|
|
668
|
+
signal.throwIfAborted();
|
|
669
|
+
await this.router.bind(channel.id, kind, msg.chatId, createdId, createdId, await this.agent(createdId), workspace.path, signal);
|
|
670
|
+
return replyText('已分叉并切换会话:{0}\n工作区:{1}\n新消息将继续这个分叉,原会话保留。', createdId, oneLine(workspace.path)) + related(replyText('查看继承记录:/history'), replyText('切回原会话:/sessions'));
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
const partial = error;
|
|
674
|
+
const attachFailed = partial?.code === 'session/workspace-attach-failed';
|
|
675
|
+
if (!createdId && attachFailed && typeof partial.details?.sessionId === 'string')
|
|
676
|
+
createdId = partial.details.sessionId;
|
|
677
|
+
if (!createdId && partial?.code === 'session/fork-unavailable')
|
|
678
|
+
throw new Error(replyText('当前会话还没有可分叉的完整回合。请完成一轮对话后再发送 /fork;也可用 /new 开始。'));
|
|
679
|
+
if (!createdId)
|
|
680
|
+
throw error;
|
|
681
|
+
return replyText('分叉已创建:{0},但未能切换,当前绑定仍保留原会话。\n{1}发送 /session {2} 接续。', createdId, attachFailed ? replyText('请先在网页检查并修复该分叉的工作区归属,然后') : replyText('可稍后'), createdId);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (command === 'model' || command === 'reasoning' || command === 'reasonings' || command === 'reasoninglist') {
|
|
685
|
+
const catalog = await this.call('sessionController', 'modelCatalog');
|
|
686
|
+
const snapshot = await this.snapshot(sessionId, signal);
|
|
687
|
+
const selected = snapshot.projections?.values?.modelSelection?.next || catalog?.default;
|
|
688
|
+
if (!selected?.provider || !selected.model)
|
|
689
|
+
throw new Error(replyText('模型:暂时无法读取'));
|
|
690
|
+
if (command === 'model') {
|
|
691
|
+
if (!input) {
|
|
692
|
+
const name = catalog.groups?.find((group) => group.id === selected.provider)?.models?.find((item) => item.id === selected.model)?.name;
|
|
693
|
+
return replyText('当前模型:{0}\n模型 ID:{1}/{2}\n推理等级:{3}', oneLine(name || selected.model), selected.provider, selected.model, selected.reasoningEffort || replyText('默认')) + related(replyText('选择其他模型:/models'), replyText('查看推理选项:/reasoning'), replyText('切换用法:/model {0}/{1} [推理等级ID]', selected.provider, selected.model));
|
|
694
|
+
}
|
|
695
|
+
if (input.split(/\s+/).length > 2)
|
|
696
|
+
throw new Error(replyText('参数过多。用法:/model 序号或provider/model [推理等级ID];可用模型:/models'));
|
|
697
|
+
const [choice, reasoningEffort] = input.split(/\s+/);
|
|
698
|
+
const id = this.resolve(scope + ':models', choice);
|
|
699
|
+
const slash = id.indexOf('/');
|
|
700
|
+
if (slash < 1)
|
|
701
|
+
throw new Error(replyText('用法:/model <序号或provider/model> [推理等级]'));
|
|
702
|
+
const result = await this.call('sessionController', 'selectModel', { sessionId, provider: id.slice(0, slash), model: id.slice(slash + 1), ...(reasoningEffort ? { reasoningEffort } : {}) });
|
|
703
|
+
return replyText('已切换模型:{0}/{1};推理:{2}\n{3}', result.selected.provider, result.selected.model, result.selected.reasoningEffort || replyText('默认'), modelDefaultHint()) + related(replyText('调整推理:/reasoning'), replyText('查看模型:/models'));
|
|
704
|
+
}
|
|
705
|
+
const model = catalog.groups?.find((group) => group.id === selected.provider)?.models?.find((item) => item.id === selected.model);
|
|
706
|
+
const efforts = model?.reasoning?.efforts || [];
|
|
707
|
+
if (!input || command !== 'reasoning') {
|
|
708
|
+
this.remember(scope + ':reasoning', efforts.map(item => this.reasoningChoice(scope, sessionId, selected, item.id)));
|
|
709
|
+
return replyText('模型:{0}\n当前推理:{1}\n{2}', oneLine(model?.name || selected.model), selected.reasoningEffort || replyText('默认'), efforts.length ? replyText('可选等级:\n') + efforts.map((item, i) => `${i + 1}. ${item.id} — ${oneLine(item.name)}`).join('\n') : replyText('当前模型没有可选推理等级。')) + related(...(efforts.length ? [replyText('切换:/reasoning {0}', '1'), replyText('也可使用 /reasoning id:等级ID;序号 15 分钟内有效。'), replyText('恢复默认:/reasoning --default'), replyText('当前模型:/model')] : [replyText('当前模型:/model'), replyText('选择其他模型:/models')]));
|
|
710
|
+
}
|
|
711
|
+
const resolved = this.resolve(scope + ':reasoning', input);
|
|
712
|
+
let effort = resolved.startsWith('id:') ? resolved.slice(3) : resolved;
|
|
713
|
+
if (resolved.startsWith('--choice ')) {
|
|
714
|
+
const token = resolved.slice(9);
|
|
715
|
+
const choice = this.reasoningChoices.get(token);
|
|
716
|
+
if (!choice || choice.scope !== scope || choice.sessionId !== sessionId || choice.provider !== selected.provider || choice.model !== selected.model || Date.now() - choice.time >= 15 * 60_000)
|
|
717
|
+
throw new Error(replyText('选择已过期,或会话、模型已变化。请发送 /reasoning 重新选择。'));
|
|
718
|
+
effort = choice.effort ?? '--default';
|
|
719
|
+
this.reasoningChoices.delete(token);
|
|
720
|
+
}
|
|
721
|
+
if (effort !== '--default' && !efforts.some(item => item.id === effort))
|
|
722
|
+
throw new Error(replyText('推理等级无效,请先 /reasoning 查看。'));
|
|
723
|
+
await this.call('sessionController', 'selectModel', { sessionId, provider: selected.provider, model: selected.model, ...(effort === '--default' ? {} : { reasoningEffort: effort }) });
|
|
724
|
+
return replyText('推理等级已设为:{0}\n{1}', effort === '--default' ? replyText('默认') : effort, modelDefaultHint()) + related(replyText('查看当前模型:/model'), replyText('查看推理选项:/reasoning'));
|
|
725
|
+
}
|
|
726
|
+
if (command === 'history') {
|
|
727
|
+
const snapshot = await this.snapshot(sessionId, signal);
|
|
728
|
+
const history = (snapshot.records ?? []).flatMap((record) => record.type === 'event' && record.event ? [record.event] : []).filter((record) => record.type === 'assistant/message' || (record.type === 'user/message' && record.data?.source?.kind === 'user'))
|
|
729
|
+
.flatMap((record) => {
|
|
730
|
+
const text = (record.data?.message?.content || record.data?.content || []).filter((part) => part.type === 'text').map((part) => part.text || '').join('\n');
|
|
731
|
+
return text.trim() ? [replyText('{0}:{1}', record.type === 'assistant/message' ? replyText('助手') : replyText('用户'), text)] : [];
|
|
732
|
+
}).join('\n\n');
|
|
733
|
+
return (history ? replyText('最近文字记录(不是完整历史)\n\n') + history : replyText('最近记录中没有可展示的文字,图片或文件请在网页查看。')) + related(replyText('继续此会话:直接发消息'), replyText('换一个会话:/sessions'), replyText('完整记录:在网页打开当前会话'));
|
|
734
|
+
}
|
|
735
|
+
const agent = await this.agent(sessionId);
|
|
736
|
+
const images = (msg.media || []).map((media) => {
|
|
737
|
+
if (media.kind !== 'image' || !media.data || !media.mediaType)
|
|
738
|
+
throw new Error(replyText('Chat 命令只支持文字和图片附件。'));
|
|
739
|
+
return { mediaType: media.mediaType, data: Buffer.from(media.data).toString('base64'), ...(media.name ? { name: media.name } : {}) };
|
|
740
|
+
});
|
|
741
|
+
this.onSession(sessionId, msg);
|
|
742
|
+
const result = await this.call('commands', 'execute', agent, msg.text.trim(), images, signal);
|
|
743
|
+
let goal;
|
|
744
|
+
if (command === 'goal') {
|
|
745
|
+
try {
|
|
746
|
+
goal = this.host.get('goals')?.get(agent);
|
|
747
|
+
}
|
|
748
|
+
catch { /* 保留宿主原始结果,附加状态查询不改变操作结果。 */ }
|
|
749
|
+
}
|
|
750
|
+
return result ? extensionReply(command, result.result, goal) : replyText('未知命令 /{0}。发送 /help 查看当前 Chat 支持的命令。', command);
|
|
751
|
+
}
|
|
752
|
+
}
|