evolclaw 3.3.0 → 3.4.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/CHANGELOG.md +36 -0
- package/README.md +7 -3
- package/dist/agents/claude-runner.js +23 -27
- package/dist/agents/codex-runner.js +90 -6
- package/dist/agents/runner-types.js +30 -0
- package/dist/aun/outbox.js +14 -2
- package/dist/channels/aun.js +506 -108
- package/dist/channels/feishu.js +29 -5
- package/dist/cli/agent-command.js +591 -0
- package/dist/cli/agent.js +15 -3
- package/dist/cli/aun-commands.js +1444 -0
- package/dist/cli/ctl-command.js +78 -0
- package/dist/cli/daemon-commands.js +2707 -0
- package/dist/cli/index.js +12 -5027
- package/dist/cli/restart-monitor.js +539 -0
- package/dist/cli/watch-logs.js +33 -0
- package/dist/core/channel-loader.js +4 -1
- package/dist/core/command/command-handler.js +1189 -0
- package/dist/core/command/menu-handler.js +1478 -0
- package/dist/core/command/slash-gate.js +142 -0
- package/dist/core/command/slash-handler.js +2090 -0
- package/dist/core/evolagent-registry.js +81 -0
- package/dist/core/evolagent.js +16 -0
- package/dist/core/message/im-renderer.js +67 -49
- package/dist/core/message/message-bridge.js +30 -9
- package/dist/core/message/message-processor.js +200 -122
- package/dist/core/message/message-queue.js +68 -0
- package/dist/core/permission.js +16 -0
- package/dist/core/session/session-manager.js +59 -13
- package/dist/core/stats/db.js +20 -0
- package/dist/core/stats/writer.js +3 -3
- package/dist/data/error-dict.json +7 -0
- package/dist/index.js +49 -6
- package/dist/ipc.js +99 -0
- package/dist/utils/cross-platform.js +35 -0
- package/dist/utils/ecweb-launch.js +49 -0
- package/dist/utils/error-utils.js +18 -5
- package/dist/utils/npm-ops.js +38 -8
- package/dist/utils/stats.js +63 -6
- package/kits/eck_manifest.json +0 -12
- package/package.json +2 -3
- package/dist/core/command-handler.js +0 -4235
- package/dist/core/message/response-depth.js +0 -56
- package/kits/templates/system-fragments/response-depth.md +0 -16
|
@@ -1,4235 +0,0 @@
|
|
|
1
|
-
import { DEFAULT_PERMISSION_MODE } from '../types.js';
|
|
2
|
-
import { hasModelSwitcher, hasPermissionController } from '../agents/runner-types.js';
|
|
3
|
-
import { getCodexEfforts } from '../agents/codex-runner.js';
|
|
4
|
-
import { renderCommandCardAsText } from './interaction-router.js';
|
|
5
|
-
import { buildEnvelope, sendInteractionPayload } from './message/message-processor.js';
|
|
6
|
-
import { resolvePaths, getPackageRoot } from '../paths.js';
|
|
7
|
-
import { logger } from '../utils/logger.js';
|
|
8
|
-
import crypto from 'crypto';
|
|
9
|
-
import path from 'path';
|
|
10
|
-
import fs from 'fs';
|
|
11
|
-
import os from 'os';
|
|
12
|
-
import { parseTriggerSet, parseTriggerUpdate } from './trigger/parser.js';
|
|
13
|
-
import { calcNextFireAt } from './trigger/scheduler.js';
|
|
14
|
-
import { checkLatestVersion, getLocalVersion, isLinkedInstall, compareVersions } from '../utils/npm-ops.js';
|
|
15
|
-
import { tryParseChannelKey } from './channel-loader.js';
|
|
16
|
-
import { loadDefaults, loadEvolclawConfig } from '../config-store.js';
|
|
17
|
-
import { execAgentAction, execAgentQuery, execAgentOptions, resolveProjectPath } from './message/command-handler-agent-control.js';
|
|
18
|
-
import { displaySessionTitle } from './session/session-title.js';
|
|
19
|
-
const allEfforts = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
20
|
-
const nonMaxEfforts = allEfforts.filter(e => e !== 'max' && e !== 'xhigh');
|
|
21
|
-
const PERMISSION_MODE_KEYS = ['auto', 'bypass', 'readonly', 'plan', 'edit', 'request', 'noask'];
|
|
22
|
-
const PERMISSION_MODE_USAGE = PERMISSION_MODE_KEYS.join('|');
|
|
23
|
-
// ── CLI 透传(menu.action name=cli action=exec)─────────────────────────
|
|
24
|
-
// 经消息通道的远程命令执行(RCE):仅 owner、白名单内只读+配置命令、无 shell、超时+截断。
|
|
25
|
-
// command → '*'(全部子命令放行) | Set(允许的子命令)。
|
|
26
|
-
// 刻意排除破坏性/进程控制/数据面:restart stop start init dev mv rpc msg group net、
|
|
27
|
-
// agent set/new/delete/enable/disable/rename/reload、aid new/delete/agentmd、storage 写操作。
|
|
28
|
-
const CLI_EXEC_WHITELIST = {
|
|
29
|
-
status: '*',
|
|
30
|
-
model: '*',
|
|
31
|
-
stats: '*',
|
|
32
|
-
agent: new Set(['list', 'show', 'get']),
|
|
33
|
-
aid: new Set(['list', 'show', 'lookup']),
|
|
34
|
-
storage: new Set(['ls', 'quota']),
|
|
35
|
-
};
|
|
36
|
-
const CLI_EXEC_TIMEOUT_MS = 15_000;
|
|
37
|
-
const CLI_EXEC_MAX_OUTPUT = 128 * 1024;
|
|
38
|
-
/** 把命令行字符串分词为 argv,尊重单/双引号,不调用 shell。 */
|
|
39
|
-
function tokenizeArgv(line) {
|
|
40
|
-
const out = [];
|
|
41
|
-
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
42
|
-
let m;
|
|
43
|
-
while ((m = re.exec(line)) !== null) {
|
|
44
|
-
out.push(m[1] ?? m[2] ?? m[3] ?? '');
|
|
45
|
-
}
|
|
46
|
-
return out;
|
|
47
|
-
}
|
|
48
|
-
function getAvailableEfforts(agent, model) {
|
|
49
|
-
if (agent.name === 'claude') {
|
|
50
|
-
return allEfforts;
|
|
51
|
-
}
|
|
52
|
-
if (agent.name === 'codex') {
|
|
53
|
-
return getCodexEfforts(model);
|
|
54
|
-
}
|
|
55
|
-
return [];
|
|
56
|
-
}
|
|
57
|
-
function formatModelUsage(_agent, _model) {
|
|
58
|
-
return '用法: /model <模型>';
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* 模型展示标签:短别名 + 实际完整 ID(如 "opus (claude-opus-4-8)")。
|
|
62
|
-
* 仅用于展示;命令值/持久化仍使用短别名。完整 ID 不可用或与短名相同时只显示短名。
|
|
63
|
-
*/
|
|
64
|
-
function modelDisplayLabel(agent, model) {
|
|
65
|
-
const full = agent.resolveModelId?.(model);
|
|
66
|
-
return full && full !== model ? `${model} (${full})` : model;
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* 写入用户级 ~/.claude/settings.json(与 Claude CLI 行为一致)
|
|
70
|
-
*/
|
|
71
|
-
function writeUserSettings(updates) {
|
|
72
|
-
try {
|
|
73
|
-
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
74
|
-
let settings = {};
|
|
75
|
-
if (fs.existsSync(settingsPath)) {
|
|
76
|
-
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
77
|
-
}
|
|
78
|
-
if (updates.model !== undefined)
|
|
79
|
-
settings.model = updates.model;
|
|
80
|
-
if (updates.effortLevel !== undefined) {
|
|
81
|
-
if (updates.effortLevel === null) {
|
|
82
|
-
delete settings.effortLevel;
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
settings.effortLevel = updates.effortLevel;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
const claudeDir = path.join(os.homedir(), '.claude');
|
|
89
|
-
if (!fs.existsSync(claudeDir)) {
|
|
90
|
-
fs.mkdirSync(claudeDir, { recursive: true });
|
|
91
|
-
}
|
|
92
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
93
|
-
return { success: true };
|
|
94
|
-
}
|
|
95
|
-
catch (error) {
|
|
96
|
-
return { success: false, error: error.message };
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* 计算两个字符串的 Levenshtein 距离(编辑距离)
|
|
101
|
-
*/
|
|
102
|
-
function levenshteinDistance(str1, str2) {
|
|
103
|
-
const len1 = str1.length;
|
|
104
|
-
const len2 = str2.length;
|
|
105
|
-
const matrix = [];
|
|
106
|
-
for (let i = 0; i <= len1; i++) {
|
|
107
|
-
matrix[i] = [i];
|
|
108
|
-
}
|
|
109
|
-
for (let j = 0; j <= len2; j++) {
|
|
110
|
-
matrix[0][j] = j;
|
|
111
|
-
}
|
|
112
|
-
for (let i = 1; i <= len1; i++) {
|
|
113
|
-
for (let j = 1; j <= len2; j++) {
|
|
114
|
-
if (str1[i - 1] === str2[j - 1]) {
|
|
115
|
-
matrix[i][j] = matrix[i - 1][j - 1];
|
|
116
|
-
}
|
|
117
|
-
else {
|
|
118
|
-
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, // 替换
|
|
119
|
-
matrix[i][j - 1] + 1, // 插入
|
|
120
|
-
matrix[i - 1][j] + 1 // 删除
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return matrix[len1][len2];
|
|
126
|
-
}
|
|
127
|
-
function formatIdleTime(ms) {
|
|
128
|
-
const seconds = Math.floor(ms / 1000);
|
|
129
|
-
const minutes = Math.floor(seconds / 60);
|
|
130
|
-
const hours = Math.floor(minutes / 60);
|
|
131
|
-
const days = Math.floor(hours / 24);
|
|
132
|
-
if (days > 0)
|
|
133
|
-
return `${days}天前`;
|
|
134
|
-
if (hours > 0)
|
|
135
|
-
return `${hours}小时前`;
|
|
136
|
-
if (minutes > 0)
|
|
137
|
-
return `${minutes}分钟前`;
|
|
138
|
-
return '刚刚';
|
|
139
|
-
}
|
|
140
|
-
function isAdminRole(role) {
|
|
141
|
-
return role === 'owner' || role === 'admin';
|
|
142
|
-
}
|
|
143
|
-
// 支持的命令列表
|
|
144
|
-
const commands = ['/new', '/pwd', '/help', '/evolhelp', '/status', '/restart', '/model', '/setmodel', '/effort', '/baseagent', '/slist', '/session', '/rename', '/stop', '/compact', '/repair', '/safe', '/fork', '/del', '/perm', '/file', '/check', '/rewind', '/activity', '/chatmode', '/dispatch', '/ask', '/resume', '/aid', '/rpc', '/storage', '/agent', '/trigger', '/upgrade'];
|
|
145
|
-
const deprecatedCommands = ['/clear'];
|
|
146
|
-
// 命令别名映射
|
|
147
|
-
const aliases = {
|
|
148
|
-
'/s': '/session',
|
|
149
|
-
'/name': '/rename',
|
|
150
|
-
'/rw': '/rewind',
|
|
151
|
-
'/base': '/baseagent',
|
|
152
|
-
};
|
|
153
|
-
// 命令快速路径前缀(所有命令都不进入消息队列)
|
|
154
|
-
const quickCommandPrefixes = ['/new', '/pwd', '/help', '/evolhelp', '/status', '/restart', '/model', '/setmodel', '/effort', '/baseagent', '/slist', '/session', '/rename', '/repair', '/fork', '/stop', '/clear', '/compact', '/safe', '/del', '/perm', '/file', '/check', '/s ', '/name', '/rewind', '/rw', '/rw ', '/activity', '/chatmode', '/dispatch', '/ask', '/resume', '/base ', '/aid', '/rpc', '/storage', '/agent', '/trigger', '/upgrade'];
|
|
155
|
-
export class CommandHandler {
|
|
156
|
-
sessionManager;
|
|
157
|
-
messageCache;
|
|
158
|
-
eventBus;
|
|
159
|
-
adapters = new Map();
|
|
160
|
-
policies = new Map();
|
|
161
|
-
channelObjects = new Map(); // name → actual channel instance (for /check)
|
|
162
|
-
channelTypeMap = new Map(); // name → channelType (for grouping)
|
|
163
|
-
processor;
|
|
164
|
-
messageQueue;
|
|
165
|
-
permissionGateway;
|
|
166
|
-
interactionRouter;
|
|
167
|
-
statsCollector;
|
|
168
|
-
agentMap;
|
|
169
|
-
primaryRunnerKey;
|
|
170
|
-
agentRegistry;
|
|
171
|
-
triggerScheduler;
|
|
172
|
-
triggerManager;
|
|
173
|
-
/**
|
|
174
|
-
* Get the runner for a (channel, baseagent) pair.
|
|
175
|
-
*
|
|
176
|
-
* Resolves the owning EvolAgent via the registry; falls back to default key.
|
|
177
|
-
* `baseagent` typically comes from `session.agentId` (e.g. 'claude').
|
|
178
|
-
*/
|
|
179
|
-
getAgent(channel, baseagent) {
|
|
180
|
-
if (channel && baseagent) {
|
|
181
|
-
const evolName = this.agentRegistry?.resolveByChannel(channel)?.name || '<unknown>';
|
|
182
|
-
const key = `${evolName}::${baseagent}`;
|
|
183
|
-
if (this.agentMap.has(key))
|
|
184
|
-
return this.agentMap.get(key);
|
|
185
|
-
}
|
|
186
|
-
if (this.agentMap.has(this.primaryRunnerKey))
|
|
187
|
-
return this.agentMap.get(this.primaryRunnerKey);
|
|
188
|
-
return this.agentMap.values().next().value;
|
|
189
|
-
}
|
|
190
|
-
/** Return the list of baseagents available to a given channel (per-EvolAgent isolation). */
|
|
191
|
-
getAvailableBaseagents(channel) {
|
|
192
|
-
const evolName = this.agentRegistry?.resolveByChannel(channel)?.name || '<unknown>';
|
|
193
|
-
const prefix = `${evolName}::`;
|
|
194
|
-
const result = [];
|
|
195
|
-
for (const key of this.agentMap.keys()) {
|
|
196
|
-
if (key.startsWith(prefix))
|
|
197
|
-
result.push(key.slice(prefix.length));
|
|
198
|
-
}
|
|
199
|
-
return result;
|
|
200
|
-
}
|
|
201
|
-
/** Extract the baseagent component from `primaryRunnerKey` (e.g. `aid::claude` → `claude`). */
|
|
202
|
-
parseDefaultBaseagent() {
|
|
203
|
-
const idx = this.primaryRunnerKey.indexOf('::');
|
|
204
|
-
return idx >= 0 ? this.primaryRunnerKey.slice(idx + 2) : this.primaryRunnerKey;
|
|
205
|
-
}
|
|
206
|
-
constructor(sessionManager, agentRunnerOrMap, messageCache, eventBus, primaryRunnerKey) {
|
|
207
|
-
this.sessionManager = sessionManager;
|
|
208
|
-
this.messageCache = messageCache;
|
|
209
|
-
this.eventBus = eventBus;
|
|
210
|
-
if (agentRunnerOrMap instanceof Map) {
|
|
211
|
-
this.agentMap = agentRunnerOrMap;
|
|
212
|
-
this.primaryRunnerKey = primaryRunnerKey || '<unknown>::claude';
|
|
213
|
-
}
|
|
214
|
-
else {
|
|
215
|
-
// 测试 / 单 runner 路径:占位 agent name 用 '<unknown>'
|
|
216
|
-
this.agentMap = new Map([[`<unknown>::${agentRunnerOrMap.name}`, agentRunnerOrMap]]);
|
|
217
|
-
this.primaryRunnerKey = `<unknown>::${agentRunnerOrMap.name}`;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
/** 注入 EvolAgentRegistry,用于判断通道是否被 EvolAgent 管理 */
|
|
221
|
-
setAgentRegistry(registry) {
|
|
222
|
-
this.agentRegistry = registry;
|
|
223
|
-
}
|
|
224
|
-
/** 注入触发器调度器(由 index.ts 在初始化后调用) */
|
|
225
|
-
setTriggerScheduler(scheduler, manager) {
|
|
226
|
-
this.triggerScheduler = scheduler;
|
|
227
|
-
this.triggerManager = manager;
|
|
228
|
-
}
|
|
229
|
-
/** 返回管理当前通道的 EvolAgent,无则返回 null */
|
|
230
|
-
getOwningAgent(channel) {
|
|
231
|
-
if (!this.agentRegistry)
|
|
232
|
-
return null;
|
|
233
|
-
return this.agentRegistry.resolveByChannel(channel);
|
|
234
|
-
}
|
|
235
|
-
/** 返回当前通道的有效项目路径:从 owning agent 取。*/
|
|
236
|
-
getEffectiveDefaultPath(channel) {
|
|
237
|
-
const owning = this.getOwningAgent(channel);
|
|
238
|
-
if (owning)
|
|
239
|
-
return owning.projectPath;
|
|
240
|
-
return process.cwd();
|
|
241
|
-
}
|
|
242
|
-
/**
|
|
243
|
-
* 持久化 baseagent.model:写到 agent config.json;找不到 owning agent 时
|
|
244
|
-
* 退到用户级 ~/.claude/settings.json(Claude 专用)。
|
|
245
|
-
*/
|
|
246
|
-
persistBaseagentModel(channel, baseagentName, newModel) {
|
|
247
|
-
const owning = this.getOwningAgent(channel);
|
|
248
|
-
if (owning) {
|
|
249
|
-
try {
|
|
250
|
-
owning.setBaseagentModel(newModel);
|
|
251
|
-
}
|
|
252
|
-
catch (e) {
|
|
253
|
-
return `⚠️ 写入 agent config 失败: ${e?.message || e}`;
|
|
254
|
-
}
|
|
255
|
-
return undefined;
|
|
256
|
-
}
|
|
257
|
-
// 无 owning agent(罕见,新结构下应当不会发生)→ 仅 Claude 走用户级 fallback
|
|
258
|
-
if (baseagentName !== 'claude') {
|
|
259
|
-
return `⚠️ 找不到通道 "${channel}" 所属的 self-agent`;
|
|
260
|
-
}
|
|
261
|
-
const updates = {};
|
|
262
|
-
if (newModel)
|
|
263
|
-
updates.model = newModel;
|
|
264
|
-
const writeResult = writeUserSettings(updates);
|
|
265
|
-
if (!writeResult.success) {
|
|
266
|
-
return `⚠️ 写入用户配置失败: ${writeResult.error}`;
|
|
267
|
-
}
|
|
268
|
-
return undefined;
|
|
269
|
-
}
|
|
270
|
-
/**
|
|
271
|
-
* 持久化 baseagent.effort:写到 agent config.json;找不到时退到用户级 settings。
|
|
272
|
-
*/
|
|
273
|
-
persistBaseagentEffort(channel, baseagentName, newEffort) {
|
|
274
|
-
const owning = this.getOwningAgent(channel);
|
|
275
|
-
if (owning) {
|
|
276
|
-
try {
|
|
277
|
-
owning.setBaseagentEffort(newEffort);
|
|
278
|
-
}
|
|
279
|
-
catch (e) {
|
|
280
|
-
return `⚠️ 写入 agent config 失败: ${e?.message || e}`;
|
|
281
|
-
}
|
|
282
|
-
return undefined;
|
|
283
|
-
}
|
|
284
|
-
if (baseagentName !== 'claude') {
|
|
285
|
-
return `⚠️ 找不到通道 "${channel}" 所属的 self-agent`;
|
|
286
|
-
}
|
|
287
|
-
const updates = { effortLevel: newEffort ?? null };
|
|
288
|
-
const writeResult = writeUserSettings(updates);
|
|
289
|
-
if (!writeResult.success) {
|
|
290
|
-
return `⚠️ 写入用户配置失败: ${writeResult.error}`;
|
|
291
|
-
}
|
|
292
|
-
return undefined;
|
|
293
|
-
}
|
|
294
|
-
/** 项目列表快捷访问(无 channel 上下文时的 fallback,尽量不用) */
|
|
295
|
-
get projects() {
|
|
296
|
-
return {};
|
|
297
|
-
}
|
|
298
|
-
/** 根据项目路径查找配置中的项目名称 */
|
|
299
|
-
getConfiguredProjectName(projectPath) {
|
|
300
|
-
return Object.entries(this.projects).find(([_, p]) => p === projectPath)?.[0];
|
|
301
|
-
}
|
|
302
|
-
/** 根据项目路径查找项目名称(未配置时回退到目录名) */
|
|
303
|
-
getProjectName(projectPath) {
|
|
304
|
-
return this.getConfiguredProjectName(projectPath) || path.basename(projectPath);
|
|
305
|
-
}
|
|
306
|
-
/** 格式化运行时间 */
|
|
307
|
-
formatUptime(ms) {
|
|
308
|
-
const sec = Math.floor(ms / 1000);
|
|
309
|
-
const d = Math.floor(sec / 86400);
|
|
310
|
-
const h = Math.floor((sec % 86400) / 3600);
|
|
311
|
-
const m = Math.floor((sec % 3600) / 60);
|
|
312
|
-
const s = sec % 60;
|
|
313
|
-
const parts = [];
|
|
314
|
-
if (d > 0)
|
|
315
|
-
parts.push(`${d}天`);
|
|
316
|
-
if (h > 0)
|
|
317
|
-
parts.push(`${h}时`);
|
|
318
|
-
if (m > 0)
|
|
319
|
-
parts.push(`${m}分`);
|
|
320
|
-
if (parts.length === 0)
|
|
321
|
-
parts.push(`${s}秒`);
|
|
322
|
-
return parts.join('');
|
|
323
|
-
}
|
|
324
|
-
/** 获取消息队列 key:话题用 session.id,主会话用 channel-channelId */
|
|
325
|
-
getQueueKey(session, _channel, _channelId) {
|
|
326
|
-
// 队列和 agent 均使用 session.id 作为 key
|
|
327
|
-
return session?.id || '';
|
|
328
|
-
}
|
|
329
|
-
/** 从 session 提取渠道预构建的回复上下文 */
|
|
330
|
-
getReplyContext(session) {
|
|
331
|
-
return session.metadata?.replyContext;
|
|
332
|
-
}
|
|
333
|
-
/**
|
|
334
|
-
* 发送 CommandCard 卡片。卡片成功返回 null(调用方直接 return),失败返回降级文本。
|
|
335
|
-
* CommandCard 不进 InteractionRouter,按钮点击由 channel 直接构造伪命令入站消息。
|
|
336
|
-
*
|
|
337
|
-
* 走统一 adapter.send(envelope, { kind: 'interaction', ... }) 入口。
|
|
338
|
-
*/
|
|
339
|
-
async sendCommandCard(opts) {
|
|
340
|
-
const adapter = this.adapters.get(opts.channel);
|
|
341
|
-
if (opts.interaction.kind.kind !== 'command-card') {
|
|
342
|
-
logger.warn(`[CommandHandler] sendCommandCard called with non-CommandCard kind`);
|
|
343
|
-
return null;
|
|
344
|
-
}
|
|
345
|
-
const card = opts.interaction.kind;
|
|
346
|
-
if (opts.canWrite === false)
|
|
347
|
-
return renderCommandCardAsText(card);
|
|
348
|
-
if (!adapter?.send)
|
|
349
|
-
return renderCommandCardAsText(card);
|
|
350
|
-
try {
|
|
351
|
-
const envelope = buildEnvelope({
|
|
352
|
-
channel: opts.channel,
|
|
353
|
-
channelId: opts.channelId,
|
|
354
|
-
agentName: this.agentRegistry?.resolveByChannel(opts.channel)?.name,
|
|
355
|
-
replyContext: opts.replyCtx,
|
|
356
|
-
});
|
|
357
|
-
const fallbackText = renderCommandCardAsText(card);
|
|
358
|
-
const messageId = await sendInteractionPayload(adapter, envelope, opts.interaction, fallbackText, opts.replyCtx);
|
|
359
|
-
if (messageId)
|
|
360
|
-
return null;
|
|
361
|
-
}
|
|
362
|
-
catch (e) {
|
|
363
|
-
logger.warn(`[CommandHandler] sendCommandCard failed: ${e}`);
|
|
364
|
-
}
|
|
365
|
-
return renderCommandCardAsText(card);
|
|
366
|
-
}
|
|
367
|
-
/**
|
|
368
|
-
* 通用降级应答入口:按 (sessionId, fallbackCommand) 查找 pending interaction 并路由。
|
|
369
|
-
* 返回 { matched: true } 表示已处理,调用方直接返回 result。
|
|
370
|
-
*/
|
|
371
|
-
async handleInteractionFallback(command, args, sessionId, userId) {
|
|
372
|
-
if (!this.interactionRouter)
|
|
373
|
-
return { matched: false };
|
|
374
|
-
const pendingId = this.interactionRouter.findPendingByCommand(sessionId, command);
|
|
375
|
-
if (!pendingId)
|
|
376
|
-
return { matched: false };
|
|
377
|
-
const initiatorId = this.interactionRouter.getInitiator(pendingId);
|
|
378
|
-
if (initiatorId && userId && initiatorId !== userId) {
|
|
379
|
-
return { matched: true, result: '⚠️ 仅卡片发起者可应答' };
|
|
380
|
-
}
|
|
381
|
-
this.interactionRouter.handle({
|
|
382
|
-
type: 'interaction.response',
|
|
383
|
-
id: pendingId,
|
|
384
|
-
action: args,
|
|
385
|
-
operatorId: userId,
|
|
386
|
-
});
|
|
387
|
-
return { matched: true, result: '✓ 已回答' };
|
|
388
|
-
}
|
|
389
|
-
/** 获取活跃会话,无会话时自动创建(话题除外) */
|
|
390
|
-
async ensureSession(channel, channelId, threadId, chatType, selfAID) {
|
|
391
|
-
if (threadId) {
|
|
392
|
-
// 话题会话:仅查询,不创建
|
|
393
|
-
const session = await this.sessionManager.getThreadSession(channel, channelId, threadId);
|
|
394
|
-
if (!session) {
|
|
395
|
-
return { error: '❌ 话题中尚未创建会话\n发送消息后自动创建' };
|
|
396
|
-
}
|
|
397
|
-
return { session };
|
|
398
|
-
}
|
|
399
|
-
const ct = chatType === 'group' ? 'group' : chatType === 'private' ? 'private' : undefined;
|
|
400
|
-
const channelType = this.resolveChannelType(channel);
|
|
401
|
-
const sid = selfAID ?? this.resolveSelfAID(channel);
|
|
402
|
-
const session = await this.sessionManager.getActiveSession(channel, channelId)
|
|
403
|
-
?? await this.sessionManager.getOrCreateSession(channel, channelId, this.getEffectiveDefaultPath(channel), undefined, undefined, undefined, undefined, ct, undefined, sid, channelType);
|
|
404
|
-
// 如果 session 已存在但 chatType 跟传入的不一致,更新
|
|
405
|
-
if (ct && session.chatType !== ct) {
|
|
406
|
-
await this.sessionManager.updateSession(session.id, { chatType: ct });
|
|
407
|
-
session.chatType = ct;
|
|
408
|
-
}
|
|
409
|
-
return { session };
|
|
410
|
-
}
|
|
411
|
-
setProcessor(processor) {
|
|
412
|
-
this.processor = processor;
|
|
413
|
-
}
|
|
414
|
-
setMessageQueue(messageQueue) {
|
|
415
|
-
this.messageQueue = messageQueue;
|
|
416
|
-
}
|
|
417
|
-
setPermissionGateway(gateway) {
|
|
418
|
-
this.permissionGateway = gateway;
|
|
419
|
-
}
|
|
420
|
-
setInteractionRouter(router) {
|
|
421
|
-
this.interactionRouter = router;
|
|
422
|
-
}
|
|
423
|
-
setStatsCollector(collector) {
|
|
424
|
-
this.statsCollector = collector;
|
|
425
|
-
}
|
|
426
|
-
registerAdapter(adapter) {
|
|
427
|
-
this.adapters.set(adapter.channelName, adapter);
|
|
428
|
-
}
|
|
429
|
-
registerChannel(name, channel, channelType) {
|
|
430
|
-
this.channelObjects.set(name, channel);
|
|
431
|
-
if (channelType)
|
|
432
|
-
this.channelTypeMap.set(name, channelType);
|
|
433
|
-
}
|
|
434
|
-
/** 将实例名解析为渠道类型(用于 session 查询) */
|
|
435
|
-
resolveChannelType(channelName) {
|
|
436
|
-
return this.channelTypeMap.get(channelName) || channelName;
|
|
437
|
-
}
|
|
438
|
-
/**
|
|
439
|
-
* 从 channel key(<type>#<selfAID>#<name>)解析本地身份 AID。
|
|
440
|
-
* 非 evolagent 通道(裸 channelType,如 'feishu')解析失败返回 undefined。
|
|
441
|
-
* aun 通道创建 session 时必须提供 selfAID,故所有 getOrCreateSession 调用都经此兜底。
|
|
442
|
-
*/
|
|
443
|
-
resolveSelfAID(channel) {
|
|
444
|
-
return tryParseChannelKey(channel)?.selfAID;
|
|
445
|
-
}
|
|
446
|
-
registerPolicy(channelName, policy) {
|
|
447
|
-
this.policies.set(channelName, policy);
|
|
448
|
-
}
|
|
449
|
-
getAdapter(channelName) {
|
|
450
|
-
// 先按实例名查找,再按 channelType 查找
|
|
451
|
-
let adapter = this.adapters.get(channelName);
|
|
452
|
-
if (adapter)
|
|
453
|
-
return adapter;
|
|
454
|
-
for (const [name, a] of this.adapters) {
|
|
455
|
-
if ((this.channelTypeMap.get(name) || name) === channelName)
|
|
456
|
-
return a;
|
|
457
|
-
}
|
|
458
|
-
return undefined;
|
|
459
|
-
}
|
|
460
|
-
getPolicy(channel) {
|
|
461
|
-
return this.policies.get(channel) || {
|
|
462
|
-
canSwitchProject: () => true,
|
|
463
|
-
canListProjects: () => true,
|
|
464
|
-
canCreateSession: () => true,
|
|
465
|
-
canDeleteSession: () => true,
|
|
466
|
-
canImportCliSession: () => true,
|
|
467
|
-
messagePrefix: () => '',
|
|
468
|
-
showMiddleResult: () => true,
|
|
469
|
-
showIdleMonitor: () => true,
|
|
470
|
-
accumulateErrors: () => true,
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
resolveMenuChatType(channel, channelId, explicit) {
|
|
474
|
-
if (explicit)
|
|
475
|
-
return explicit;
|
|
476
|
-
const active = this.sessionManager.getActiveSessionSync(channel, channelId);
|
|
477
|
-
return active?.chatType === 'group' ? 'group' : 'private';
|
|
478
|
-
}
|
|
479
|
-
canReadTopics(role) {
|
|
480
|
-
return role !== 'anonymous';
|
|
481
|
-
}
|
|
482
|
-
canDeleteTopic(role, chatType, topic, userId) {
|
|
483
|
-
if (role === 'anonymous')
|
|
484
|
-
return false;
|
|
485
|
-
if (isAdminRole(role))
|
|
486
|
-
return true;
|
|
487
|
-
if (chatType === 'group')
|
|
488
|
-
return false;
|
|
489
|
-
return !!userId && topic.metadata?.peerId === userId;
|
|
490
|
-
}
|
|
491
|
-
buildTopicMenuItem(s) {
|
|
492
|
-
const displayName = displaySessionTitle(s.name, s.threadId || s.id.slice(0, 8));
|
|
493
|
-
const item = {
|
|
494
|
-
value: s.threadId,
|
|
495
|
-
label: displayName,
|
|
496
|
-
};
|
|
497
|
-
if (s.agentSessionId) {
|
|
498
|
-
item.agentSessionId = s.agentSessionId;
|
|
499
|
-
const fileInfo = this.sessionManager.getSessionFileInfo(s.projectPath, s.agentSessionId, s.agentId);
|
|
500
|
-
if (fileInfo.turns)
|
|
501
|
-
item.turns = fileInfo.turns;
|
|
502
|
-
const firstMsg = this.sessionManager.readSessionFirstMessage(s.projectPath, s.agentSessionId, s.agentId);
|
|
503
|
-
if (firstMsg)
|
|
504
|
-
item.preview = firstMsg.length > 80 ? firstMsg.slice(0, 80) + '...' : firstMsg;
|
|
505
|
-
}
|
|
506
|
-
if (s.updatedAt)
|
|
507
|
-
item.lastActive = s.updatedAt;
|
|
508
|
-
return item;
|
|
509
|
-
}
|
|
510
|
-
/**
|
|
511
|
-
* 返回结构化命令菜单(供 menu.query 使用)
|
|
512
|
-
* owner 看到全部命令,admin 看到管理级命令(不含 owner-only),guest 仅看到用户级命令
|
|
513
|
-
*/
|
|
514
|
-
getMenuItems(role, chatType = 'private') {
|
|
515
|
-
const isOwner = role === 'owner';
|
|
516
|
-
const isAdmin = role === 'owner' || role === 'admin';
|
|
517
|
-
const canReadTopic = role !== 'anonymous';
|
|
518
|
-
const items = [];
|
|
519
|
-
if (!isAdmin && chatType === 'group') {
|
|
520
|
-
return [
|
|
521
|
-
...(canReadTopic ? [{
|
|
522
|
-
group: '话题管理',
|
|
523
|
-
commands: [
|
|
524
|
-
{ cmd: '/topic', label: '话题管理', desc: '查看当前聊天的话题会话', next: { type: 'select', dynamic: true } },
|
|
525
|
-
]
|
|
526
|
-
}] : []),
|
|
527
|
-
{
|
|
528
|
-
group: '其他',
|
|
529
|
-
commands: [
|
|
530
|
-
{ cmd: '/status', label: '显示会话状态' },
|
|
531
|
-
{ cmd: '/check', label: '检查渠道健康' },
|
|
532
|
-
{ cmd: '/help', label: '显示帮助信息' },
|
|
533
|
-
]
|
|
534
|
-
}
|
|
535
|
-
];
|
|
536
|
-
}
|
|
537
|
-
items.push({
|
|
538
|
-
group: '会话管理',
|
|
539
|
-
commands: [
|
|
540
|
-
{ cmd: '/new', label: '创建新会话', desc: '清空历史,开始全新对话', next: { type: 'text' } },
|
|
541
|
-
{ cmd: '/s', label: '切换会话', desc: '切换到同项目下的其他会话', next: { type: 'select', dynamic: true } },
|
|
542
|
-
...(canReadTopic ? [{ cmd: '/topic', label: '话题管理', desc: '查看与管理当前聊天的话题会话', next: { type: 'select', dynamic: true } }] : []),
|
|
543
|
-
{ cmd: '/name', label: '重命名当前会话', desc: '为当前会话设置一个易识别的名称', next: { type: 'text' } },
|
|
544
|
-
{ cmd: '/del', label: '删除指定会话', desc: '永久删除一个非活跃会话', next: { type: 'select', dynamic: true } },
|
|
545
|
-
...(isAdmin ? [
|
|
546
|
-
{ cmd: '/fork', label: '分支当前会话', desc: '基于当前会话创建独立分支', next: { type: 'text' } },
|
|
547
|
-
{ cmd: '/rewind', label: '查看历史/撤销指定轮次', desc: '回退会话到指定轮次,可选择撤销文件改动' },
|
|
548
|
-
{ cmd: '/compact', label: '压缩会话上下文', desc: '将长对话压缩为摘要以节省 token' },
|
|
549
|
-
] : []),
|
|
550
|
-
]
|
|
551
|
-
});
|
|
552
|
-
if (isAdmin) {
|
|
553
|
-
items.push({
|
|
554
|
-
group: 'Agent 与模型',
|
|
555
|
-
commands: [
|
|
556
|
-
{ cmd: '/baseagent', label: '切换 Agent 后端', desc: '切换当前会话使用的 AI 后端', next: { type: 'select', dynamic: true } },
|
|
557
|
-
{ cmd: '/model', label: '切换模型', desc: '切换当前 Agent 使用的模型版本', next: { type: 'select', dynamic: true } },
|
|
558
|
-
{ cmd: '/effort', label: '切换推理强度', desc: '调整模型推理深度,影响响应速度与质量', next: { type: 'select', items: [
|
|
559
|
-
{ value: 'low', label: 'Low' },
|
|
560
|
-
{ value: 'medium', label: 'Medium' },
|
|
561
|
-
{ value: 'high', label: 'High' },
|
|
562
|
-
{ value: 'max', label: 'Max' },
|
|
563
|
-
] } },
|
|
564
|
-
{ cmd: '/chatmode', label: '切换会话模式', desc: '控制 Agent 主动性(被动响应或主动推进)', next: { type: 'select', items: [
|
|
565
|
-
{ value: 'interactive', label: '交互模式', desc: '仅在收到消息时响应' },
|
|
566
|
-
{ value: 'proactive', label: '主动模式', desc: 'Agent 可主动推进任务' },
|
|
567
|
-
] } },
|
|
568
|
-
{ cmd: '/dispatch', label: '切换分发模式', desc: '控制群聊消息过滤(仅@提及或广播响应)', next: { type: 'select', items: [
|
|
569
|
-
{ value: 'mention', label: '@ 提及', desc: '仅在被 @ 提及时响应' },
|
|
570
|
-
{ value: 'broadcast', label: '广播', desc: '响应群内所有消息' },
|
|
571
|
-
] } },
|
|
572
|
-
]
|
|
573
|
-
});
|
|
574
|
-
items.push({
|
|
575
|
-
group: '权限管理',
|
|
576
|
-
commands: [
|
|
577
|
-
{ cmd: '/perm', label: '权限模式管理', desc: '控制工具调用的审批策略', next: { type: 'select', items: [
|
|
578
|
-
...(isOwner ? [
|
|
579
|
-
{ value: 'auto', label: '自动模式', desc: '根据风险等级自动决定是否审批' },
|
|
580
|
-
{ value: 'bypass', label: '免审批模式', desc: '跳过所有工具审批确认' },
|
|
581
|
-
{ value: 'readonly', label: '只读模式', desc: '允许读取和临时目录写入,拒绝项目文件修改' },
|
|
582
|
-
{ value: 'plan', label: '计划模式', desc: '仅允许只读操作,写操作需审批' },
|
|
583
|
-
{ value: 'edit', label: '编辑模式', desc: '允许文件编辑,其他操作需审批' },
|
|
584
|
-
{ value: 'request', label: '请求模式', desc: '所有操作均需审批' },
|
|
585
|
-
{ value: 'noask', label: '静默模式', desc: '不弹出审批,自动拒绝未授权操作' },
|
|
586
|
-
] : []),
|
|
587
|
-
{ value: 'allow', label: '允许此操作', desc: '本次允许当前待审批操作' },
|
|
588
|
-
{ value: 'always', label: '始终允许', desc: '永久允许同类操作' },
|
|
589
|
-
{ value: 'deny', label: '拒绝此操作', desc: '拒绝当前待审批操作' },
|
|
590
|
-
] } },
|
|
591
|
-
]
|
|
592
|
-
});
|
|
593
|
-
items.push({
|
|
594
|
-
group: '运维',
|
|
595
|
-
commands: [
|
|
596
|
-
{ cmd: '/status', label: '显示会话状态', desc: '查看当前会话、项目、Agent 的详细状态' },
|
|
597
|
-
{ cmd: '/stop', label: '中断当前任务', desc: '立即中断正在执行的 Agent 任务' },
|
|
598
|
-
{ cmd: '/check', label: '检查渠道状态', desc: '检查各消息渠道的连接健康状态' },
|
|
599
|
-
{ cmd: '/activity', label: '控制中间输出显示', desc: '设置工具调用过程的可见范围', next: { type: 'select', items: [
|
|
600
|
-
{ value: 'all', label: '全部显示', desc: '所有用户均可见中间输出' },
|
|
601
|
-
{ value: 'dm', label: '仅私聊', desc: '仅私聊中显示中间输出' },
|
|
602
|
-
{ value: 'owner', label: '仅 owner 私聊', desc: '仅 owner 的私聊中显示' },
|
|
603
|
-
{ value: 'none', label: '不显示', desc: '关闭所有中间输出' },
|
|
604
|
-
] } },
|
|
605
|
-
...(isAdmin ? [
|
|
606
|
-
{ cmd: '/restart', label: '重启服务', desc: '重启整个 EvolClaw 服务进程' },
|
|
607
|
-
] : []),
|
|
608
|
-
...(isOwner ? [
|
|
609
|
-
{ cmd: '/file', label: '发送项目内文件', desc: '将项目目录内的文件发送给用户' },
|
|
610
|
-
] : []),
|
|
611
|
-
]
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
|
-
else {
|
|
615
|
-
items.push({
|
|
616
|
-
group: '其他',
|
|
617
|
-
commands: [
|
|
618
|
-
{ cmd: '/status', label: '显示会话状态', desc: '查看当前会话的基本状态' },
|
|
619
|
-
{ cmd: '/check', label: '检查渠道健康', desc: '检查消息渠道连接状态' },
|
|
620
|
-
]
|
|
621
|
-
});
|
|
622
|
-
}
|
|
623
|
-
items.push({
|
|
624
|
-
group: '帮助',
|
|
625
|
-
commands: [
|
|
626
|
-
{ cmd: '/help', label: '显示帮助信息', desc: '列出所有可用命令及说明' },
|
|
627
|
-
]
|
|
628
|
-
});
|
|
629
|
-
return items;
|
|
630
|
-
}
|
|
631
|
-
/** 动态子菜单:根据 cmd 路径返回选项列表(供 menu.query + cmd 使用) */
|
|
632
|
-
async getSubMenuItems(cmd, channel, channelId, userId, args, overrideIdentity, _explicitChatType) {
|
|
633
|
-
const session = await this.sessionManager.getActiveSession(channel, channelId);
|
|
634
|
-
// ── 进程级 /agent list(owners 鉴权) ──
|
|
635
|
-
if (cmd === '/agent') {
|
|
636
|
-
if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
|
|
637
|
-
throw { code: 'FORBIDDEN', message: '操作需要 owner 权限' };
|
|
638
|
-
}
|
|
639
|
-
const res = await execAgentOptions(args);
|
|
640
|
-
if ('error' in res)
|
|
641
|
-
throw { code: res.code, message: res.error };
|
|
642
|
-
return res.data.agents.map(ag => ({ value: ag.aid, label: ag.name || ag.aid, desc: ag.status }));
|
|
643
|
-
}
|
|
644
|
-
// ── 关系级 /trigger list(每个 trigger 一个 MenuItem) ──
|
|
645
|
-
if (cmd === '/trigger') {
|
|
646
|
-
const owningAgent = this.getOwningAgent(channel);
|
|
647
|
-
const manager = (owningAgent?.triggerManager ?? this.triggerManager);
|
|
648
|
-
if (!manager)
|
|
649
|
-
return [];
|
|
650
|
-
const scope = args?.options === 'all' ? 'all' : 'enabled';
|
|
651
|
-
const role = (overrideIdentity ?? this.sessionManager.resolveIdentity(channel, userId)).role;
|
|
652
|
-
const isAdmin = role === 'owner' || role === 'admin';
|
|
653
|
-
const all = manager.listAll();
|
|
654
|
-
const list = scope === 'all' ? all.active.concat(all.history) : manager.listActive();
|
|
655
|
-
const visible = isAdmin ? list
|
|
656
|
-
: list.filter((t) => t.createdByPeerId === (userId ?? '') && t.createdByChannel === channel);
|
|
657
|
-
return visible.map((t) => ({
|
|
658
|
-
// 透传完整 trigger 字段(ECWeb Triggers 表逐列渲染需要)
|
|
659
|
-
...t,
|
|
660
|
-
value: t.id,
|
|
661
|
-
label: t.name,
|
|
662
|
-
desc: `${t.scheduleType}${t.nextFireAt ? ` | 下次 ${new Date(t.nextFireAt).toLocaleString()}` : ''}`,
|
|
663
|
-
// 状态标识:history 条目带 doneReason(fired/cancelled/expired),active 条目恒为 'active'
|
|
664
|
-
status: t.doneReason ?? 'active',
|
|
665
|
-
}));
|
|
666
|
-
}
|
|
667
|
-
if (cmd === '/topic') {
|
|
668
|
-
const identity = overrideIdentity ?? this.sessionManager.resolveIdentity(channel, userId);
|
|
669
|
-
if (!this.canReadTopics(identity.role)) {
|
|
670
|
-
throw { code: 'FORBIDDEN', message: '无权限查看话题' };
|
|
671
|
-
}
|
|
672
|
-
const sessions = await this.sessionManager.listSessions(channel, channelId);
|
|
673
|
-
return sessions
|
|
674
|
-
.filter(s => !!s.threadId)
|
|
675
|
-
.map(s => this.buildTopicMenuItem(s));
|
|
676
|
-
}
|
|
677
|
-
if (cmd === '/s' || cmd === '/session' || cmd === '/del') {
|
|
678
|
-
const sessions = await this.sessionManager.listSessions(channel, channelId);
|
|
679
|
-
const active = cmd === '/del' ? await this.sessionManager.getActiveSession(channel, channelId) : null;
|
|
680
|
-
const currentSession = session;
|
|
681
|
-
const items = sessions
|
|
682
|
-
.filter(s => !s.threadId)
|
|
683
|
-
.filter(s => !active || s.id !== active.id)
|
|
684
|
-
.map(s => {
|
|
685
|
-
const displayName = displaySessionTitle(s.name, s.id.slice(0, 8));
|
|
686
|
-
const item = {
|
|
687
|
-
value: s.name || s.id.slice(0, 8),
|
|
688
|
-
label: displayName,
|
|
689
|
-
selected: currentSession ? s.id === currentSession.id : false,
|
|
690
|
-
};
|
|
691
|
-
if (s.agentSessionId) {
|
|
692
|
-
item.agentSessionId = s.agentSessionId;
|
|
693
|
-
const fileInfo = this.sessionManager.getSessionFileInfo(s.projectPath, s.agentSessionId, s.agentId);
|
|
694
|
-
if (fileInfo.turns)
|
|
695
|
-
item.turns = fileInfo.turns;
|
|
696
|
-
const firstMsg = this.sessionManager.readSessionFirstMessage(s.projectPath, s.agentSessionId, s.agentId);
|
|
697
|
-
if (firstMsg)
|
|
698
|
-
item.preview = firstMsg.length > 80 ? firstMsg.slice(0, 80) + '…' : firstMsg;
|
|
699
|
-
}
|
|
700
|
-
if (s.updatedAt)
|
|
701
|
-
item.lastActive = s.updatedAt;
|
|
702
|
-
return item;
|
|
703
|
-
});
|
|
704
|
-
if (cmd === '/s' || cmd === '/session') {
|
|
705
|
-
items.push({ value: 'cli', label: '查看 CLI 会话', desc: '列出未导入的 CLI 本地会话' });
|
|
706
|
-
}
|
|
707
|
-
return items;
|
|
708
|
-
}
|
|
709
|
-
if (cmd === '/baseagent') {
|
|
710
|
-
const currentAgent = session?.agentId;
|
|
711
|
-
return this.getAvailableBaseagents(channel).map(name => ({ value: name, label: name, selected: name === currentAgent }));
|
|
712
|
-
}
|
|
713
|
-
if (cmd === '/model') {
|
|
714
|
-
const agent = this.getAgent(channel, session?.agentId);
|
|
715
|
-
if (hasModelSwitcher(agent) && agent.listModels) {
|
|
716
|
-
const models = await agent.listModels() ?? [];
|
|
717
|
-
const currentModel = agent.getModel();
|
|
718
|
-
if (models.length > 0)
|
|
719
|
-
return models.map((m) => ({ value: m, label: modelDisplayLabel(agent, m), selected: m === currentModel }));
|
|
720
|
-
}
|
|
721
|
-
return null;
|
|
722
|
-
}
|
|
723
|
-
// if (cmd === '/restart') {
|
|
724
|
-
// const isOwner = userId ? this.sessionManager.resolveIdentity(channel, userId).role === 'owner' : false;
|
|
725
|
-
// // 列出所有 channel type
|
|
726
|
-
// const visibleTypes = new Set<string>();
|
|
727
|
-
// for (const [name] of this.adapters) {
|
|
728
|
-
// const t = this.channelTypeMap.get(name);
|
|
729
|
-
// if (t) visibleTypes.add(t);
|
|
730
|
-
// }
|
|
731
|
-
// const channels = [...visibleTypes].map(type => ({ value: type, label: type, desc: '重连此类型所有渠道实例' }));
|
|
732
|
-
// if (isOwner) channels.unshift({ value: '', label: '重启服务', desc: '重启整个 EvolClaw 服务进程' });
|
|
733
|
-
// return channels;
|
|
734
|
-
// }
|
|
735
|
-
if (cmd === '/activity') {
|
|
736
|
-
const currentMode = this.agentRegistry?.getShowActivities?.(channel) ?? 'all';
|
|
737
|
-
return [
|
|
738
|
-
{ value: 'all', label: '全部显示', selected: currentMode === 'all' },
|
|
739
|
-
{ value: 'dm', label: '仅私聊显示', selected: currentMode === 'dm-only' },
|
|
740
|
-
{ value: 'owner', label: '仅 owner 私聊显示', selected: currentMode === 'owner-dm-only' },
|
|
741
|
-
{ value: 'none', label: '全部静默', selected: currentMode === 'none' },
|
|
742
|
-
];
|
|
743
|
-
}
|
|
744
|
-
if (cmd === '/effort') {
|
|
745
|
-
const agent = this.getAgent(channel, session?.agentId);
|
|
746
|
-
const currentModel = hasModelSwitcher(agent) ? agent.getModel() : agent.name;
|
|
747
|
-
const efforts = getAvailableEfforts(agent, currentModel);
|
|
748
|
-
const currentEffort = agent.getEffort?.() || 'auto';
|
|
749
|
-
const allItems = [...efforts, 'auto'];
|
|
750
|
-
return allItems.map(e => ({ value: e, label: e === 'auto' ? 'auto (SDK默认)' : e, selected: e === currentEffort }));
|
|
751
|
-
}
|
|
752
|
-
if (cmd === '/chatmode') {
|
|
753
|
-
// 无活跃会话时,selected 跟随 evolagent.config.chatmode.private 默认值
|
|
754
|
-
let currentMode;
|
|
755
|
-
if (session?.sessionMode) {
|
|
756
|
-
currentMode = session.sessionMode;
|
|
757
|
-
}
|
|
758
|
-
else {
|
|
759
|
-
const evolagent = this.agentRegistry?.resolveByChannel(channel);
|
|
760
|
-
currentMode = evolagent?.config?.chatmode?.private || 'interactive';
|
|
761
|
-
}
|
|
762
|
-
return [
|
|
763
|
-
{ value: 'interactive', label: '交互模式', selected: currentMode === 'interactive' },
|
|
764
|
-
{ value: 'proactive', label: '主动模式', selected: currentMode === 'proactive' },
|
|
765
|
-
];
|
|
766
|
-
}
|
|
767
|
-
if (cmd === '/dispatch') {
|
|
768
|
-
const currentMode = session?.metadata?.dispatchMode ?? null;
|
|
769
|
-
return [
|
|
770
|
-
{ value: 'mention', label: '@提及时响应', selected: currentMode === 'mention' },
|
|
771
|
-
{ value: 'broadcast', label: '所有消息响应', selected: currentMode === 'broadcast' },
|
|
772
|
-
];
|
|
773
|
-
}
|
|
774
|
-
if (cmd === '/perm') {
|
|
775
|
-
const currentMode = session?.metadata?.permissionMode ?? DEFAULT_PERMISSION_MODE;
|
|
776
|
-
const permAgent = this.getAgent(channel, session?.agentId);
|
|
777
|
-
const validModes = hasPermissionController(permAgent)
|
|
778
|
-
? permAgent.listModes().filter(m => m.available).map(m => m.key)
|
|
779
|
-
: [...PERMISSION_MODE_KEYS];
|
|
780
|
-
return validModes.map(m => ({ value: m, label: m, selected: m === currentMode }));
|
|
781
|
-
}
|
|
782
|
-
return null;
|
|
783
|
-
}
|
|
784
|
-
// ── Menu Protocol exec ────────────────────────────────────────────────
|
|
785
|
-
//
|
|
786
|
-
// 三个入口对应 menu.query / menu.update / menu.action:
|
|
787
|
-
// execMenuQuery — 查询某项当前值(无会话时多数 fallback 到 evolagent config)
|
|
788
|
-
// execMenuUpdate — 写入新值(持久化到 session 或 evolagent config)
|
|
789
|
-
// execMenuAction — 触发动词(stop/restart/new/delete/compact/fork/switch/check/upgrade)
|
|
790
|
-
//
|
|
791
|
-
// 所有方法返回 { data } 或 { error, code? }。code 是结构化错误码(NO_ACTIVE_SESSION 等),
|
|
792
|
-
// 客户端可据此决定降级策略。message-bridge 把 code 透传到 menu.response。
|
|
793
|
-
async loadMenuContext(channel, channelId) {
|
|
794
|
-
const session = await this.sessionManager.getActiveSession(channel, channelId);
|
|
795
|
-
const evolagent = this.agentRegistry?.resolveByChannel(channel) ?? null;
|
|
796
|
-
return { session, evolagent };
|
|
797
|
-
}
|
|
798
|
-
requireSession(s) {
|
|
799
|
-
return s ? null : { error: '当前无活跃会话', code: 'NO_ACTIVE_SESSION' };
|
|
800
|
-
}
|
|
801
|
-
/** menu.query — 查询当前值。 */
|
|
802
|
-
async execMenuQuery(cmd, channel, channelId, userId, args, _explicitChatType) {
|
|
803
|
-
const cmdBase = cmd.trim().split(' ')[0];
|
|
804
|
-
if (!cmdBase)
|
|
805
|
-
return { error: '缺少命令', code: 'MISSING_CMD' };
|
|
806
|
-
const { session, evolagent } = await this.loadMenuContext(channel, channelId);
|
|
807
|
-
// ── 进程级 /agent(owners 鉴权) ──
|
|
808
|
-
if (cmdBase === '/agent') {
|
|
809
|
-
if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
|
|
810
|
-
return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
|
|
811
|
-
}
|
|
812
|
-
return await execAgentQuery(args);
|
|
813
|
-
}
|
|
814
|
-
if (cmdBase === '/pwd') {
|
|
815
|
-
const sessPath = session?.projectPath;
|
|
816
|
-
const fallbackPath = evolagent?.config?.projects?.defaultPath;
|
|
817
|
-
const path = sessPath ?? fallbackPath ?? null;
|
|
818
|
-
const name = path ? this.getProjectName(path) : null;
|
|
819
|
-
return { data: { name, path } };
|
|
820
|
-
}
|
|
821
|
-
if (cmdBase === '/session' || cmdBase === '/s') {
|
|
822
|
-
if (!session) {
|
|
823
|
-
return { data: { status: 'no-session' } };
|
|
824
|
-
}
|
|
825
|
-
const sessionKey = this.getQueueKey(session, channel, channelId);
|
|
826
|
-
const sessionAgent = this.getAgent(channel, session.agentId);
|
|
827
|
-
const isProcessing = this.messageQueue.isProcessing(sessionKey) || sessionAgent.hasActiveStream(sessionKey);
|
|
828
|
-
const queueLength = this.messageQueue.getQueueLength(sessionKey);
|
|
829
|
-
const health = await this.sessionManager.getHealthStatus(session.id);
|
|
830
|
-
let processingDuration;
|
|
831
|
-
if (isProcessing && session.processingState) {
|
|
832
|
-
const elapsed = Date.now() - parseInt(session.processingState, 10);
|
|
833
|
-
if (!isNaN(elapsed) && elapsed > 0)
|
|
834
|
-
processingDuration = Math.floor(elapsed / 1000);
|
|
835
|
-
}
|
|
836
|
-
let turns = 0;
|
|
837
|
-
if (session.agentSessionId) {
|
|
838
|
-
const fileInfo = this.sessionManager.getSessionFileInfo(session.projectPath, session.agentSessionId, session.agentId);
|
|
839
|
-
turns = fileInfo.turns;
|
|
840
|
-
}
|
|
841
|
-
const data = {
|
|
842
|
-
name: session.name || null,
|
|
843
|
-
agentSessionId: session.agentSessionId || null,
|
|
844
|
-
status: isProcessing ? 'processing' : 'idle',
|
|
845
|
-
createdAt: session.createdAt,
|
|
846
|
-
updatedAt: session.updatedAt,
|
|
847
|
-
};
|
|
848
|
-
if (processingDuration !== undefined)
|
|
849
|
-
data.processingDuration = processingDuration;
|
|
850
|
-
if (queueLength > 0)
|
|
851
|
-
data.queueLength = queueLength;
|
|
852
|
-
if (turns > 0)
|
|
853
|
-
data.turns = turns;
|
|
854
|
-
if (health.lastSuccessTime)
|
|
855
|
-
data.lastSuccess = health.lastSuccessTime;
|
|
856
|
-
if (health.consecutiveErrors)
|
|
857
|
-
data.consecutiveErrors = health.consecutiveErrors;
|
|
858
|
-
if (health.lastError)
|
|
859
|
-
data.lastError = { type: health.lastErrorType || 'unknown', message: health.lastError.substring(0, 100) };
|
|
860
|
-
return { data };
|
|
861
|
-
}
|
|
862
|
-
if (cmdBase === '/topic') {
|
|
863
|
-
const identity = this.sessionManager.resolveIdentity(channel, userId);
|
|
864
|
-
if (!this.canReadTopics(identity.role)) {
|
|
865
|
-
return { error: '无权限查看话题', code: 'FORBIDDEN' };
|
|
866
|
-
}
|
|
867
|
-
const target = (args?.target ?? '').toString().trim();
|
|
868
|
-
if (!target)
|
|
869
|
-
return { error: '缺少 args.target', code: 'MISSING_VALUE' };
|
|
870
|
-
const topic = await this.sessionManager.getThreadSession(channel, channelId, target);
|
|
871
|
-
if (!topic)
|
|
872
|
-
return { error: '话题不存在', code: 'NOT_FOUND' };
|
|
873
|
-
const sessionKey = this.getQueueKey(topic, channel, channelId);
|
|
874
|
-
const sessionAgent = this.getAgent(channel, topic.agentId);
|
|
875
|
-
const isProcessing = this.messageQueue.isProcessing(sessionKey) || sessionAgent.hasActiveStream(sessionKey);
|
|
876
|
-
const queueLength = this.messageQueue.getQueueLength(sessionKey);
|
|
877
|
-
const health = await this.sessionManager.getHealthStatus(topic.id);
|
|
878
|
-
let processingDuration;
|
|
879
|
-
if (isProcessing && topic.processingState) {
|
|
880
|
-
const elapsed = Date.now() - parseInt(topic.processingState, 10);
|
|
881
|
-
if (!isNaN(elapsed) && elapsed > 0)
|
|
882
|
-
processingDuration = Math.floor(elapsed / 1000);
|
|
883
|
-
}
|
|
884
|
-
let turns = 0;
|
|
885
|
-
if (topic.agentSessionId) {
|
|
886
|
-
turns = this.sessionManager.getSessionFileInfo(topic.projectPath, topic.agentSessionId, topic.agentId).turns;
|
|
887
|
-
}
|
|
888
|
-
const data = {
|
|
889
|
-
threadId: topic.threadId,
|
|
890
|
-
name: topic.name || null,
|
|
891
|
-
agentSessionId: topic.agentSessionId || null,
|
|
892
|
-
status: isProcessing ? 'processing' : 'idle',
|
|
893
|
-
createdAt: topic.createdAt,
|
|
894
|
-
updatedAt: topic.updatedAt,
|
|
895
|
-
};
|
|
896
|
-
if (processingDuration !== undefined)
|
|
897
|
-
data.processingDuration = processingDuration;
|
|
898
|
-
if (queueLength > 0)
|
|
899
|
-
data.queueLength = queueLength;
|
|
900
|
-
if (turns > 0)
|
|
901
|
-
data.turns = turns;
|
|
902
|
-
if (health.lastSuccessTime)
|
|
903
|
-
data.lastSuccess = health.lastSuccessTime;
|
|
904
|
-
if (health.consecutiveErrors)
|
|
905
|
-
data.consecutiveErrors = health.consecutiveErrors;
|
|
906
|
-
if (health.lastError)
|
|
907
|
-
data.lastError = { type: health.lastErrorType || 'unknown', message: health.lastError.substring(0, 100) };
|
|
908
|
-
return { data };
|
|
909
|
-
}
|
|
910
|
-
if (cmdBase === '/baseagent') {
|
|
911
|
-
const value = session?.agentId ?? evolagent?.config?.active_baseagent ?? null;
|
|
912
|
-
return { data: { baseagent: value } };
|
|
913
|
-
}
|
|
914
|
-
if (cmdBase === '/model') {
|
|
915
|
-
if (session) {
|
|
916
|
-
const agent = this.getAgent(channel, session.agentId);
|
|
917
|
-
if (hasModelSwitcher(agent))
|
|
918
|
-
return { data: { model: agent.getModel() ?? null } };
|
|
919
|
-
}
|
|
920
|
-
const ba = evolagent?.config?.active_baseagent;
|
|
921
|
-
const block = ba && evolagent ? evolagent.config.baseagents?.[ba] : undefined;
|
|
922
|
-
return { data: { model: block?.model ?? null } };
|
|
923
|
-
}
|
|
924
|
-
if (cmdBase === '/effort') {
|
|
925
|
-
if (session) {
|
|
926
|
-
const agent = this.getAgent(channel, session.agentId);
|
|
927
|
-
const e = agent.getEffort?.();
|
|
928
|
-
if (e !== undefined)
|
|
929
|
-
return { data: { effort: e } };
|
|
930
|
-
}
|
|
931
|
-
const ba = evolagent?.config?.active_baseagent;
|
|
932
|
-
const block = ba && evolagent ? evolagent.config.baseagents?.[ba] : undefined;
|
|
933
|
-
const fallbackField = ba === 'codex' ? (block?.effort ?? block?.reasoning) : block?.effort;
|
|
934
|
-
return { data: { effort: fallbackField ?? null } };
|
|
935
|
-
}
|
|
936
|
-
if (cmdBase === '/chatmode') {
|
|
937
|
-
const sessionMode = session?.sessionMode;
|
|
938
|
-
const fallback = evolagent?.config?.chatmode?.private;
|
|
939
|
-
return { data: { mode: sessionMode || fallback || 'interactive' } };
|
|
940
|
-
}
|
|
941
|
-
if (cmdBase === '/dispatch') {
|
|
942
|
-
const chatType = session?.chatType || 'private';
|
|
943
|
-
if (chatType !== 'group') {
|
|
944
|
-
return { error: 'dispatch 仅在群聊会话中有效', code: 'NOT_APPLICABLE' };
|
|
945
|
-
}
|
|
946
|
-
const sessionMode = session?.metadata?.dispatchMode;
|
|
947
|
-
const fallback = evolagent?.config?.dispatch;
|
|
948
|
-
return { data: { mode: sessionMode ?? fallback ?? null } };
|
|
949
|
-
}
|
|
950
|
-
if (cmdBase === '/observable') {
|
|
951
|
-
return { data: { observable: evolagent?.getObservable() ?? false } };
|
|
952
|
-
}
|
|
953
|
-
if (cmdBase === '/perm') {
|
|
954
|
-
const need = this.requireSession(session);
|
|
955
|
-
if (need)
|
|
956
|
-
return need;
|
|
957
|
-
const currentMode = session.metadata?.permissionMode ?? DEFAULT_PERMISSION_MODE;
|
|
958
|
-
return { data: { mode: currentMode } };
|
|
959
|
-
}
|
|
960
|
-
if (cmdBase === '/activity') {
|
|
961
|
-
const currentMode = this.agentRegistry?.getShowActivities?.(channel) ?? 'all';
|
|
962
|
-
return { data: { mode: currentMode } };
|
|
963
|
-
}
|
|
964
|
-
if (cmdBase === '/system') {
|
|
965
|
-
if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
|
|
966
|
-
return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
|
|
967
|
-
}
|
|
968
|
-
const owningAgent = this.getOwningAgent(channel);
|
|
969
|
-
const data = {
|
|
970
|
-
agent: owningAgent?.name ?? 'DefaultAgent',
|
|
971
|
-
channel: this.resolveChannelType(channel),
|
|
972
|
-
pid: process.pid,
|
|
973
|
-
node: process.version,
|
|
974
|
-
uptime: Math.floor(process.uptime()),
|
|
975
|
-
};
|
|
976
|
-
try {
|
|
977
|
-
const pkgPath = path.join(getPackageRoot(), 'package.json');
|
|
978
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
979
|
-
if (pkg?.version)
|
|
980
|
-
data.version = pkg.version;
|
|
981
|
-
}
|
|
982
|
-
catch { }
|
|
983
|
-
try {
|
|
984
|
-
const fp = path.join(getPackageRoot(), 'node_modules', '@agentunion', 'fastaun', 'package.json');
|
|
985
|
-
const fp2 = JSON.parse(fs.readFileSync(fp, 'utf-8'));
|
|
986
|
-
if (fp2?.version)
|
|
987
|
-
data.fastaunVersion = fp2.version;
|
|
988
|
-
}
|
|
989
|
-
catch { }
|
|
990
|
-
const channels = owningAgent?.channelInstanceNames?.() ?? [];
|
|
991
|
-
if (channels.length)
|
|
992
|
-
data.channels = channels;
|
|
993
|
-
return { data };
|
|
994
|
-
}
|
|
995
|
-
return { error: `不支持 query: ${cmdBase}`, code: 'NOT_SUPPORTED' };
|
|
996
|
-
}
|
|
997
|
-
/** menu.update — 写入新值。 */
|
|
998
|
-
async execMenuUpdate(cmd, value, channel, channelId, userId, overrideIdentity) {
|
|
999
|
-
const cmdBase = cmd.trim().split(' ')[0];
|
|
1000
|
-
if (!cmdBase)
|
|
1001
|
-
return { error: '缺少命令', code: 'MISSING_CMD' };
|
|
1002
|
-
const arg = value.trim();
|
|
1003
|
-
if (!arg)
|
|
1004
|
-
return { error: '缺少 value 参数', code: 'MISSING_VALUE' };
|
|
1005
|
-
const { session, evolagent } = await this.loadMenuContext(channel, channelId);
|
|
1006
|
-
const identity = overrideIdentity ?? this.sessionManager.resolveIdentity(channel, userId);
|
|
1007
|
-
// ── 关系级 /trigger update(调度参数,value 为 JSON 字符串) ──
|
|
1008
|
-
if (cmdBase === '/trigger') {
|
|
1009
|
-
const owningAgent = this.getOwningAgent(channel);
|
|
1010
|
-
const manager = (owningAgent?.triggerManager ?? this.triggerManager);
|
|
1011
|
-
const scheduler = (owningAgent?.triggerScheduler ?? this.triggerScheduler);
|
|
1012
|
-
if (!manager || !scheduler)
|
|
1013
|
-
return { error: '触发器功能未启用', code: 'NOT_SUPPORTED' };
|
|
1014
|
-
let patch;
|
|
1015
|
-
try {
|
|
1016
|
-
patch = JSON.parse(arg);
|
|
1017
|
-
}
|
|
1018
|
-
catch {
|
|
1019
|
-
return { error: 'value 需为 JSON', code: 'INVALID_ARGS' };
|
|
1020
|
-
}
|
|
1021
|
-
if (!patch?.nameOrId)
|
|
1022
|
-
return { error: '缺少 nameOrId', code: 'INVALID_ARGS' };
|
|
1023
|
-
const isAdmin = identity.role === 'owner' || identity.role === 'admin';
|
|
1024
|
-
if (!isAdmin && !userId)
|
|
1025
|
-
return { error: '无法确认身份,请确保渠道提供发送者 ID', code: 'FORBIDDEN' };
|
|
1026
|
-
const trigger = isAdmin
|
|
1027
|
-
? (manager.getByName(patch.nameOrId) ?? manager.getById(patch.nameOrId))
|
|
1028
|
-
: (manager.getByNameScoped(patch.nameOrId, userId ?? '', channel) ?? manager.getByIdScoped(patch.nameOrId, userId ?? '', channel));
|
|
1029
|
-
if (!trigger)
|
|
1030
|
-
return { error: '触发器不存在或无权限', code: 'NOT_FOUND' };
|
|
1031
|
-
const fields = {};
|
|
1032
|
-
if (patch.scheduleType !== undefined)
|
|
1033
|
-
fields.scheduleType = patch.scheduleType;
|
|
1034
|
-
if (patch.scheduleValue !== undefined)
|
|
1035
|
-
fields.scheduleValue = String(patch.scheduleValue);
|
|
1036
|
-
if (patch.prompt !== undefined)
|
|
1037
|
-
fields.prompt = String(patch.prompt);
|
|
1038
|
-
// 调度参数变化时重算 nextFireAt——先校验避免 NaN 污染 scheduler heap
|
|
1039
|
-
if (fields.scheduleType !== undefined || fields.scheduleValue !== undefined) {
|
|
1040
|
-
const effType = fields.scheduleType ?? trigger.scheduleType;
|
|
1041
|
-
const effValue = fields.scheduleValue ?? trigger.scheduleValue;
|
|
1042
|
-
const schedErr = validateScheduleParams(effType, effValue);
|
|
1043
|
-
if (schedErr)
|
|
1044
|
-
return { error: schedErr, code: 'INVALID_ARGS' };
|
|
1045
|
-
fields.nextFireAt = calcNextFireAt(effType, effValue, Date.now());
|
|
1046
|
-
}
|
|
1047
|
-
let updated;
|
|
1048
|
-
try {
|
|
1049
|
-
updated = manager.update(trigger.id, fields);
|
|
1050
|
-
}
|
|
1051
|
-
catch (err) {
|
|
1052
|
-
return { error: `更新失败:${err?.message || err}`, code: 'INVALID_ARGS' };
|
|
1053
|
-
}
|
|
1054
|
-
scheduler.update(updated);
|
|
1055
|
-
return { data: { id: updated.id, nextFireAt: updated.nextFireAt } };
|
|
1056
|
-
}
|
|
1057
|
-
if (cmdBase === '/baseagent') {
|
|
1058
|
-
const valid = this.getAvailableBaseagents(channel);
|
|
1059
|
-
if (valid.length && !valid.includes(arg)) {
|
|
1060
|
-
return { error: `无效 baseagent: ${arg},可选: ${valid.join(' / ')}`, code: 'INVALID_VALUE' };
|
|
1061
|
-
}
|
|
1062
|
-
// 当前会话切换走 slash 命令的完整逻辑(涉及 runner 状态、session.agentId 重新挂载等)
|
|
1063
|
-
// 仅在 slash 命令成功后才持久化到 evolagent config,避免失败时配置已落盘
|
|
1064
|
-
if (session && session.agentId !== arg) {
|
|
1065
|
-
const result = await this._handleInternal(`/baseagent ${arg}`, channel, channelId, undefined, userId);
|
|
1066
|
-
const payload = result;
|
|
1067
|
-
if (payload?.kind === 'command.error') {
|
|
1068
|
-
return { error: payload.text || '切换失败', code: 'EXEC_FAILED' };
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
// 持久化到 evolagent config(影响后续新会话)
|
|
1072
|
-
if (evolagent)
|
|
1073
|
-
evolagent.setActiveBaseagent(arg);
|
|
1074
|
-
return { data: { baseagent: arg } };
|
|
1075
|
-
}
|
|
1076
|
-
if (cmdBase === '/model') {
|
|
1077
|
-
const agent = this.getAgent(channel, session?.agentId);
|
|
1078
|
-
if (hasModelSwitcher(agent)) {
|
|
1079
|
-
const models = (await agent.listModels?.()) ?? [];
|
|
1080
|
-
if (models.length && !models.includes(arg)) {
|
|
1081
|
-
return { error: `无效模型: ${arg}`, code: 'INVALID_VALUE' };
|
|
1082
|
-
}
|
|
1083
|
-
agent.setModel(arg);
|
|
1084
|
-
}
|
|
1085
|
-
if (evolagent)
|
|
1086
|
-
evolagent.setBaseagentModel(arg);
|
|
1087
|
-
return { data: { model: arg } };
|
|
1088
|
-
}
|
|
1089
|
-
if (cmdBase === '/effort') {
|
|
1090
|
-
const agent = this.getAgent(channel, session?.agentId);
|
|
1091
|
-
const currentModel = hasModelSwitcher(agent) ? agent.getModel() : agent.name;
|
|
1092
|
-
const validEfforts = getAvailableEfforts(agent, currentModel);
|
|
1093
|
-
const allValid = [...validEfforts, 'auto'];
|
|
1094
|
-
if (!allValid.includes(arg)) {
|
|
1095
|
-
return { error: `无效推理强度: ${arg},可选: ${allValid.join(' / ')}`, code: 'INVALID_VALUE' };
|
|
1096
|
-
}
|
|
1097
|
-
if (typeof agent.setEffort === 'function') {
|
|
1098
|
-
agent.setEffort(arg === 'auto' ? undefined : arg);
|
|
1099
|
-
}
|
|
1100
|
-
if (evolagent)
|
|
1101
|
-
evolagent.setBaseagentEffort(arg === 'auto' ? undefined : arg);
|
|
1102
|
-
return { data: { effort: arg } };
|
|
1103
|
-
}
|
|
1104
|
-
if (cmdBase === '/chatmode') {
|
|
1105
|
-
if (arg !== 'interactive' && arg !== 'proactive') {
|
|
1106
|
-
return { error: `无效模式: ${arg}`, code: 'INVALID_VALUE' };
|
|
1107
|
-
}
|
|
1108
|
-
if (session) {
|
|
1109
|
-
const chatType = session.chatType || 'private';
|
|
1110
|
-
if (chatType === 'group' && identity.role !== 'owner' && identity.role !== 'admin') {
|
|
1111
|
-
return { error: '无权限:群聊中仅管理员可切换', code: 'NO_PERMISSION' };
|
|
1112
|
-
}
|
|
1113
|
-
await this.sessionManager.updateSession(session.id, { sessionMode: arg });
|
|
1114
|
-
this.eventBus.publish({ type: 'session:chat-mode-changed', sessionId: session.id, mode: arg, timestamp: Date.now() });
|
|
1115
|
-
}
|
|
1116
|
-
else {
|
|
1117
|
-
if (evolagent)
|
|
1118
|
-
evolagent.setChatmodePrivate(arg);
|
|
1119
|
-
}
|
|
1120
|
-
return { data: { mode: arg } };
|
|
1121
|
-
}
|
|
1122
|
-
if (cmdBase === '/dispatch') {
|
|
1123
|
-
if (arg !== 'mention' && arg !== 'broadcast') {
|
|
1124
|
-
return { error: `无效模式: ${arg}`, code: 'INVALID_VALUE' };
|
|
1125
|
-
}
|
|
1126
|
-
const chatType = session?.chatType;
|
|
1127
|
-
if (!session || chatType !== 'group') {
|
|
1128
|
-
return { error: 'dispatch 仅在群聊会话中有效', code: 'NOT_APPLICABLE' };
|
|
1129
|
-
}
|
|
1130
|
-
if (identity.role !== 'owner' && identity.role !== 'admin') {
|
|
1131
|
-
return { error: '无权限:群聊中仅管理员可切换', code: 'NO_PERMISSION' };
|
|
1132
|
-
}
|
|
1133
|
-
const metadata = { ...(session.metadata || {}), dispatchMode: arg };
|
|
1134
|
-
await this.sessionManager.updateSession(session.id, { metadata });
|
|
1135
|
-
this.eventBus.publish({ type: 'session:dispatch-mode-changed', sessionId: session.id, mode: arg, timestamp: Date.now() });
|
|
1136
|
-
return { data: { mode: arg } };
|
|
1137
|
-
}
|
|
1138
|
-
if (cmdBase === '/perm') {
|
|
1139
|
-
const need = this.requireSession(session);
|
|
1140
|
-
if (need)
|
|
1141
|
-
return need;
|
|
1142
|
-
if (identity.role !== 'owner')
|
|
1143
|
-
return { error: '无权限', code: 'NO_PERMISSION' };
|
|
1144
|
-
const permAgent = this.getAgent(channel, session.agentId);
|
|
1145
|
-
const validModes = hasPermissionController(permAgent)
|
|
1146
|
-
? permAgent.listModes().filter(m => m.available).map(m => m.key)
|
|
1147
|
-
: [...PERMISSION_MODE_KEYS];
|
|
1148
|
-
if (!validModes.includes(arg))
|
|
1149
|
-
return { error: `无效模式: ${arg}`, code: 'INVALID_VALUE' };
|
|
1150
|
-
const metadata = { ...(session.metadata || {}), permissionMode: arg };
|
|
1151
|
-
await this.sessionManager.updateSession(session.id, { metadata });
|
|
1152
|
-
return { data: { mode: arg } };
|
|
1153
|
-
}
|
|
1154
|
-
if (cmdBase === '/activity') {
|
|
1155
|
-
const modeMap = { all: 'all', dm: 'dm-only', owner: 'owner-dm-only', none: 'none' };
|
|
1156
|
-
const newMode = modeMap[arg];
|
|
1157
|
-
if (!newMode)
|
|
1158
|
-
return { error: `无效模式: ${arg},可选: all / dm / owner / none`, code: 'INVALID_VALUE' };
|
|
1159
|
-
if (identity.role !== 'owner')
|
|
1160
|
-
return { error: '中间输出模式切换仅限 owner', code: 'NO_PERMISSION' };
|
|
1161
|
-
if (!this.agentRegistry?.setShowActivities)
|
|
1162
|
-
return { error: '找不到通道所属 agent,无法持久化', code: 'EXEC_FAILED' };
|
|
1163
|
-
this.agentRegistry.setShowActivities(channel, newMode);
|
|
1164
|
-
return { data: { mode: newMode } };
|
|
1165
|
-
}
|
|
1166
|
-
if (cmdBase === '/observable') {
|
|
1167
|
-
if (identity.role !== 'owner')
|
|
1168
|
-
return { error: '观察者模式仅限 owner 开关', code: 'NO_PERMISSION' };
|
|
1169
|
-
if (arg !== 'true' && arg !== 'false')
|
|
1170
|
-
return { error: `无效值: ${arg},可选: true / false`, code: 'INVALID_VALUE' };
|
|
1171
|
-
if (!evolagent)
|
|
1172
|
-
return { error: '找不到通道所属 agent,无法持久化', code: 'EXEC_FAILED' };
|
|
1173
|
-
evolagent.setObservable(arg === 'true');
|
|
1174
|
-
return { data: { observable: arg === 'true' } };
|
|
1175
|
-
}
|
|
1176
|
-
return { error: `不支持 update: ${cmdBase}`, code: 'NOT_SUPPORTED' };
|
|
1177
|
-
}
|
|
1178
|
-
/** menu.action — 触发动词。 */
|
|
1179
|
-
async execMenuAction(cmd, action, args, channel, channelId, userId, overrideIdentity, explicitChatType) {
|
|
1180
|
-
const cmdBase = cmd.trim().split(' ')[0];
|
|
1181
|
-
if (!cmdBase)
|
|
1182
|
-
return { error: '缺少命令', code: 'MISSING_CMD' };
|
|
1183
|
-
if (!action)
|
|
1184
|
-
return { error: '缺少 action', code: 'MISSING_VALUE' };
|
|
1185
|
-
const { session } = await this.loadMenuContext(channel, channelId);
|
|
1186
|
-
const identity = overrideIdentity ?? this.sessionManager.resolveIdentity(channel, userId);
|
|
1187
|
-
// ── 进程级 /agent(owners 鉴权,不依赖 session/channel) ──
|
|
1188
|
-
// NOTE(D5): 本次进程级 /agent 仅按 evolclaw.json owners 鉴权,任意 evolagent 的 AUN
|
|
1189
|
-
// channel 均可作为入口。part1(daemon 控制 AID)落地后,应叠加 isControlChannel(channelId)
|
|
1190
|
-
// 闸:仅控制 AID channel 上的 /agent /system 生效。见 part1 计划。
|
|
1191
|
-
if (cmdBase === '/agent') {
|
|
1192
|
-
if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
|
|
1193
|
-
return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
|
|
1194
|
-
}
|
|
1195
|
-
const a = { ...(args ?? {}) };
|
|
1196
|
-
if (action === 'create') {
|
|
1197
|
-
a.project = resolveProjectPath(a.project, a.aid ?? '', loadDefaults());
|
|
1198
|
-
}
|
|
1199
|
-
// reload / disable / delete 会中断 agent 正在处理的任务,执行前检查是否繁忙。
|
|
1200
|
-
// 队列按 agent 名计数,故先用 registry 把 aid 解析成 name;force 跳过。
|
|
1201
|
-
if ((action === 'reload' || action === 'disable' || action === 'delete') && a.aid && !a.force) {
|
|
1202
|
-
const handle = this.agentRegistry?.get(a.aid) ?? null;
|
|
1203
|
-
const agentName = handle?.name;
|
|
1204
|
-
if (agentName) {
|
|
1205
|
-
const busy = this.messageQueue.getProcessingCountByAgent(agentName)
|
|
1206
|
-
+ this.messageQueue.getQueueLengthByAgent(agentName);
|
|
1207
|
-
if (busy > 0) {
|
|
1208
|
-
return { error: `该 Agent 有 ${busy} 个任务执行中`, code: 'BUSY' };
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
return await execAgentAction(action, a, userId ?? '');
|
|
1213
|
-
}
|
|
1214
|
-
// ── 关系级 /trigger(不走 owners;复用 isAdmin + scoped 逻辑,D4 直调底层) ──
|
|
1215
|
-
if (cmdBase === '/trigger') {
|
|
1216
|
-
const role = identity.role;
|
|
1217
|
-
const isAdmin = role === 'owner' || role === 'admin';
|
|
1218
|
-
const owningAgent = this.getOwningAgent(channel);
|
|
1219
|
-
const manager = (owningAgent?.triggerManager ?? this.triggerManager);
|
|
1220
|
-
const scheduler = (owningAgent?.triggerScheduler ?? this.triggerScheduler);
|
|
1221
|
-
if (!manager || !scheduler)
|
|
1222
|
-
return { error: '触发器功能未启用', code: 'NOT_SUPPORTED' };
|
|
1223
|
-
if (action === 'set') {
|
|
1224
|
-
// args 结构化 → 直接组装 ParsedTriggerSet(绕过 parseTriggerSet 文本解析,无注入风险)
|
|
1225
|
-
if (!args?.scheduleType || !args?.scheduleValue || !args?.prompt) {
|
|
1226
|
-
return { error: '缺少必填参数:scheduleType / scheduleValue / prompt', code: 'INVALID_ARGS' };
|
|
1227
|
-
}
|
|
1228
|
-
// menu 路径绕过了 parseTriggerSet 的校验,必须自行校验枚举/数值,
|
|
1229
|
-
// 否则非法值会传到 calcNextFireAt 产出 NaN nextFireAt,污染 scheduler heap。
|
|
1230
|
-
const schedErr = validateScheduleParams(args.scheduleType, String(args.scheduleValue));
|
|
1231
|
-
if (schedErr)
|
|
1232
|
-
return { error: schedErr, code: 'INVALID_ARGS' };
|
|
1233
|
-
const strategy = args.targetSessionStrategy ?? 'latest';
|
|
1234
|
-
if (!['latest', 'current', 'thread'].includes(strategy)) {
|
|
1235
|
-
return { error: `无效 targetSessionStrategy: ${strategy}`, code: 'INVALID_ARGS' };
|
|
1236
|
-
}
|
|
1237
|
-
const parsed = {
|
|
1238
|
-
scheduleType: args.scheduleType,
|
|
1239
|
-
scheduleValue: String(args.scheduleValue),
|
|
1240
|
-
prompt: String(args.prompt),
|
|
1241
|
-
name: args.name,
|
|
1242
|
-
targetChannel: args.targetChannel,
|
|
1243
|
-
targetChannelId: args.targetChannelId,
|
|
1244
|
-
targetThreadId: args.targetThreadId,
|
|
1245
|
-
targetSessionStrategy: strategy,
|
|
1246
|
-
agentId: args.agentId,
|
|
1247
|
-
};
|
|
1248
|
-
const r = await this.registerTriggerFromParsed(parsed, channel, channelId, userId ?? '', undefined);
|
|
1249
|
-
if (!r.ok)
|
|
1250
|
-
return { error: r.error, code: /已存在|exists|重复/.test(r.error) ? 'CONFLICT' : 'INVALID_ARGS' };
|
|
1251
|
-
return { data: { id: r.trigger.id, name: r.trigger.name, nextFireAt: r.trigger.nextFireAt } };
|
|
1252
|
-
}
|
|
1253
|
-
if (action === 'cancel') {
|
|
1254
|
-
const nameOrId = args?.nameOrId;
|
|
1255
|
-
if (!nameOrId)
|
|
1256
|
-
return { error: '缺少 nameOrId', code: 'INVALID_ARGS' };
|
|
1257
|
-
if (!isAdmin && !userId)
|
|
1258
|
-
return { error: '无法确认身份,请确保渠道提供发送者 ID', code: 'FORBIDDEN' };
|
|
1259
|
-
const trigger = isAdmin
|
|
1260
|
-
? (manager.getByName(nameOrId) ?? manager.getById(nameOrId))
|
|
1261
|
-
: (manager.getByNameScoped(nameOrId, userId ?? '', channel) ?? manager.getByIdScoped(nameOrId, userId ?? '', channel));
|
|
1262
|
-
if (!trigger)
|
|
1263
|
-
return { error: '触发器不存在或无权限', code: 'NOT_FOUND' };
|
|
1264
|
-
manager.moveToDone(trigger.id, 'cancelled');
|
|
1265
|
-
scheduler.cancel(trigger.id);
|
|
1266
|
-
this.eventBus.publish({ type: 'trigger:cancelled', triggerId: trigger.id, name: trigger.name, by: userId ?? '' });
|
|
1267
|
-
return { data: { id: trigger.id, cancelled: true } };
|
|
1268
|
-
}
|
|
1269
|
-
return { error: `不支持的 trigger action: ${action}`, code: 'INVALID_ARGS' };
|
|
1270
|
-
}
|
|
1271
|
-
if (cmdBase === '/topic') {
|
|
1272
|
-
if (action !== 'delete') {
|
|
1273
|
-
return { error: `不支持的 topic action: ${action}`, code: 'NOT_SUPPORTED' };
|
|
1274
|
-
}
|
|
1275
|
-
const target = (args?.target ?? '').toString().trim();
|
|
1276
|
-
if (!target)
|
|
1277
|
-
return { error: '缺少 args.target', code: 'MISSING_VALUE' };
|
|
1278
|
-
const topic = await this.sessionManager.getThreadSession(channel, channelId, target);
|
|
1279
|
-
if (!topic)
|
|
1280
|
-
return { error: '话题不存在', code: 'NOT_FOUND' };
|
|
1281
|
-
const chatType = this.resolveMenuChatType(channel, channelId, explicitChatType);
|
|
1282
|
-
if (!this.canDeleteTopic(identity.role, chatType, topic, userId)) {
|
|
1283
|
-
return { error: '无权限删除话题', code: 'FORBIDDEN' };
|
|
1284
|
-
}
|
|
1285
|
-
const success = await this.sessionManager.unbindSession(topic.id);
|
|
1286
|
-
if (!success)
|
|
1287
|
-
return { error: '删除失败', code: 'DELETE_FAILED' };
|
|
1288
|
-
this.eventBus.publish({ type: 'session:deleted', sessionId: topic.id });
|
|
1289
|
-
const targetAgent = this.getAgent(channel, topic.agentId);
|
|
1290
|
-
await targetAgent.closeSession?.(topic.id);
|
|
1291
|
-
return { data: { deleted: true } };
|
|
1292
|
-
}
|
|
1293
|
-
// ── /session 系列 ──
|
|
1294
|
-
if (cmdBase === '/session' || cmdBase === '/s') {
|
|
1295
|
-
if (action === 'stop') {
|
|
1296
|
-
if (!session)
|
|
1297
|
-
return { error: '当前无活跃会话', code: 'NO_ACTIVE_SESSION' };
|
|
1298
|
-
const sessionKey = this.getQueueKey(session, channel, channelId);
|
|
1299
|
-
const sessionAgent = this.getAgent(channel, session.agentId);
|
|
1300
|
-
const hasActive = sessionAgent.hasActiveStream(sessionKey);
|
|
1301
|
-
const queueLength = this.messageQueue.getQueueLength(sessionKey);
|
|
1302
|
-
if (queueLength === 0 && !hasActive) {
|
|
1303
|
-
return { error: '当前没有正在处理的任务', code: 'NO_ACTIVE_TASK' };
|
|
1304
|
-
}
|
|
1305
|
-
await sessionAgent.interrupt(sessionKey);
|
|
1306
|
-
this.eventBus.publish({
|
|
1307
|
-
type: 'task:interrupted',
|
|
1308
|
-
sessionId: sessionKey,
|
|
1309
|
-
reason: 'stop',
|
|
1310
|
-
agentName: this.agentRegistry?.resolveByChannel(channel)?.name ?? '<unknown>',
|
|
1311
|
-
});
|
|
1312
|
-
this.sessionManager.clearProcessing(sessionKey);
|
|
1313
|
-
return { data: { action: 'stop', success: true } };
|
|
1314
|
-
}
|
|
1315
|
-
if (action === 'new') {
|
|
1316
|
-
const name = (args?.name ?? '').toString().trim();
|
|
1317
|
-
return await this.delegateAsAction(action, name ? `/new ${name}` : '/new', channel, channelId, userId, { enrichSession: true });
|
|
1318
|
-
}
|
|
1319
|
-
if (action === 'delete') {
|
|
1320
|
-
const target = (args?.target ?? '').toString().trim();
|
|
1321
|
-
if (!target)
|
|
1322
|
-
return { error: '缺少 args.target', code: 'MISSING_VALUE' };
|
|
1323
|
-
return await this.delegateAsAction(action, `/del ${target}`, channel, channelId, userId);
|
|
1324
|
-
}
|
|
1325
|
-
if (action === 'switch') {
|
|
1326
|
-
const target = (args?.target ?? '').toString().trim();
|
|
1327
|
-
if (!target)
|
|
1328
|
-
return { error: '缺少 args.target', code: 'MISSING_VALUE' };
|
|
1329
|
-
return await this.delegateAsAction(action, `/s ${target}`, channel, channelId, userId, { enrichSession: true });
|
|
1330
|
-
}
|
|
1331
|
-
if (action === 'compact') {
|
|
1332
|
-
const need = this.requireSession(session);
|
|
1333
|
-
if (need)
|
|
1334
|
-
return need;
|
|
1335
|
-
return await this.delegateAsAction(action, '/compact', channel, channelId, userId);
|
|
1336
|
-
}
|
|
1337
|
-
if (action === 'fork') {
|
|
1338
|
-
const need = this.requireSession(session);
|
|
1339
|
-
if (need)
|
|
1340
|
-
return need;
|
|
1341
|
-
const name = (args?.name ?? '').toString().trim();
|
|
1342
|
-
return await this.delegateAsAction(action, name ? `/fork ${name}` : '/fork', channel, channelId, userId, { enrichSession: true });
|
|
1343
|
-
}
|
|
1344
|
-
return { error: `不支持的 session action: ${action}`, code: 'NOT_SUPPORTED' };
|
|
1345
|
-
}
|
|
1346
|
-
// ── /system 系列 ──
|
|
1347
|
-
if (cmdBase === '/system') {
|
|
1348
|
-
// D1 迁移:进程级鉴权统一查 evolclaw.json owners,替代各 action 内联的 identity.role 判断
|
|
1349
|
-
if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
|
|
1350
|
-
return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
|
|
1351
|
-
}
|
|
1352
|
-
if (action === 'restart') {
|
|
1353
|
-
const restartInfo = { channel, channelId, timestamp: Date.now() };
|
|
1354
|
-
fs.writeFileSync(path.join(resolvePaths().dataDir, 'restart-pending.json'), JSON.stringify(restartInfo));
|
|
1355
|
-
const { spawn } = await import('child_process');
|
|
1356
|
-
spawn('node', [path.join(getPackageRoot(), 'dist', 'cli', 'index.js'), 'restart-monitor'], {
|
|
1357
|
-
detached: true,
|
|
1358
|
-
stdio: 'ignore',
|
|
1359
|
-
env: { ...process.env, EVOLCLAW_HOME: resolvePaths().root }
|
|
1360
|
-
}).unref();
|
|
1361
|
-
this.eventBus.publish({ type: 'system:restart', channel, channelId });
|
|
1362
|
-
setTimeout(() => { process.kill(process.pid, 'SIGTERM'); }, 1000);
|
|
1363
|
-
return { data: { action: 'restart', success: true } };
|
|
1364
|
-
}
|
|
1365
|
-
if (action === 'check') {
|
|
1366
|
-
const r = await this.delegateAsAction(action, '/check', channel, channelId, userId, { overrideIdentity });
|
|
1367
|
-
const structured = r.data?.structured ?? null;
|
|
1368
|
-
if (structured)
|
|
1369
|
-
return { data: { ...r.data, ...structured } };
|
|
1370
|
-
return r;
|
|
1371
|
-
}
|
|
1372
|
-
if (action === 'upgrade') {
|
|
1373
|
-
const devMode = isLinkedInstall();
|
|
1374
|
-
const localEvolclaw = getLocalVersion();
|
|
1375
|
-
// fastaun 本地版本:从 node_modules 读取(与 menu.query name=system 一致)
|
|
1376
|
-
let localFastaun = null;
|
|
1377
|
-
try {
|
|
1378
|
-
const fp = path.join(getPackageRoot(), 'node_modules', '@agentunion', 'fastaun', 'package.json');
|
|
1379
|
-
localFastaun = JSON.parse(fs.readFileSync(fp, 'utf-8'))?.version ?? null;
|
|
1380
|
-
}
|
|
1381
|
-
catch { }
|
|
1382
|
-
const [evolclawRemote, fastaunRemote, ecwebRemote] = await Promise.all([
|
|
1383
|
-
checkLatestVersion('evolclaw'),
|
|
1384
|
-
checkLatestVersion('@agentunion/fastaun'),
|
|
1385
|
-
checkLatestVersion('evolclaw-web'),
|
|
1386
|
-
]);
|
|
1387
|
-
const cmp = (local, remote) => !!(local && remote && compareVersions(local, remote) < 0);
|
|
1388
|
-
return {
|
|
1389
|
-
data: {
|
|
1390
|
-
devMode,
|
|
1391
|
-
evolclaw: { local: localEvolclaw, remote: evolclawRemote, hasUpdate: cmp(localEvolclaw, evolclawRemote) },
|
|
1392
|
-
fastaun: { local: localFastaun, remote: fastaunRemote, hasUpdate: cmp(localFastaun, fastaunRemote) },
|
|
1393
|
-
// ecweb 本地版本由 ECWeb 进程自身注入(data.ecwebVersion),此处仅给 remote
|
|
1394
|
-
ecweb: { remote: ecwebRemote },
|
|
1395
|
-
},
|
|
1396
|
-
};
|
|
1397
|
-
}
|
|
1398
|
-
return { error: `不支持的 system action: ${action}`, code: 'NOT_SUPPORTED' };
|
|
1399
|
-
}
|
|
1400
|
-
// ── /cli 透传 ──
|
|
1401
|
-
if (cmdBase === '/cli') {
|
|
1402
|
-
if (action !== 'exec')
|
|
1403
|
-
return { error: `不支持的 cli action: ${action}`, code: 'NOT_SUPPORTED' };
|
|
1404
|
-
if (identity.role !== 'owner')
|
|
1405
|
-
return { error: '无权限:CLI 执行仅限 owner', code: 'NO_PERMISSION' };
|
|
1406
|
-
const argv = Array.isArray(args?.argv) ? args.argv.map((x) => String(x))
|
|
1407
|
-
: typeof args?.command === 'string' ? tokenizeArgv(args.command)
|
|
1408
|
-
: null;
|
|
1409
|
-
if (!argv || argv.length === 0)
|
|
1410
|
-
return { error: '缺少 argv 或 command', code: 'MISSING_VALUE' };
|
|
1411
|
-
const allowed = CLI_EXEC_WHITELIST[argv[0]];
|
|
1412
|
-
if (!allowed)
|
|
1413
|
-
return { error: `命令不在白名单: ${argv[0]}`, code: 'NOT_ALLOWED' };
|
|
1414
|
-
if (allowed !== '*' && !allowed.has(argv[1] ?? '')) {
|
|
1415
|
-
return { error: `子命令不在白名单: ${argv[0]} ${argv[1] ?? ''}`, code: 'NOT_ALLOWED' };
|
|
1416
|
-
}
|
|
1417
|
-
return await this.execCliPassthrough(argv);
|
|
1418
|
-
}
|
|
1419
|
-
return { error: `不支持 action: ${cmdBase}`, code: 'NOT_SUPPORTED' };
|
|
1420
|
-
}
|
|
1421
|
-
/** ECWeb 专用入口:注入 owner identity,进程级操作检查 owners 非空。不暴露 cli。 */
|
|
1422
|
-
async execMenuForEcweb(payload) {
|
|
1423
|
-
const id = payload?.id ?? '';
|
|
1424
|
-
const name = payload?.name;
|
|
1425
|
-
if (name === 'cli' || payload?.cmd === '/cli') {
|
|
1426
|
-
return { type: 'menu.response', id, name, error: { code: 'NOT_SUPPORTED', message: 'cli 不在 ECWeb 控制范围' } };
|
|
1427
|
-
}
|
|
1428
|
-
const isProcessLevel = name === 'system' || name === 'agent';
|
|
1429
|
-
const owners = loadEvolclawConfig().owners ?? [];
|
|
1430
|
-
if (isProcessLevel && owners.length === 0) {
|
|
1431
|
-
return { type: 'menu.response', id, name, error: { code: 'FORBIDDEN', message: '请在 evolclaw.json 配置 owners 后使用进程级操作' } };
|
|
1432
|
-
}
|
|
1433
|
-
const ECWEB_CHANNEL = '__ecweb__';
|
|
1434
|
-
// payload.agent(aid 或 name)时,用该 agent 的首个 channel 实例作为 channel 参数,
|
|
1435
|
-
// 让 execMenuQuery / execMenuUpdate 能按真实 agent 解析 model/effort/perm 等会话级配置。
|
|
1436
|
-
// system / agent 两个进程级 name 不走此路径,仍用 ECWEB_CHANNEL。
|
|
1437
|
-
const agentChannelKey = (() => {
|
|
1438
|
-
if (!payload?.agent || isProcessLevel)
|
|
1439
|
-
return ECWEB_CHANNEL;
|
|
1440
|
-
const handle = this.agentRegistry?.get(payload.agent) ?? null;
|
|
1441
|
-
return handle?.channelInstanceNames()?.[0] ?? ECWEB_CHANNEL;
|
|
1442
|
-
})();
|
|
1443
|
-
const ownerIdentity = { role: 'owner', mode: 'interactive' };
|
|
1444
|
-
// 进程级操作用 owners[0] 让 isProcessLevelOwner() 通过;其余传 undefined
|
|
1445
|
-
const userId = isProcessLevel ? (owners[0] ?? '') : undefined;
|
|
1446
|
-
const nameMap = {
|
|
1447
|
-
pwd: '/pwd', session: '/session', baseagent: '/baseagent', model: '/model',
|
|
1448
|
-
topic: '/topic',
|
|
1449
|
-
effort: '/effort', chatmode: '/chatmode', dispatch: '/dispatch',
|
|
1450
|
-
permission: '/perm', activity: '/activity', system: '/system',
|
|
1451
|
-
agent: '/agent', trigger: '/trigger',
|
|
1452
|
-
};
|
|
1453
|
-
const cmd = name ? (nameMap[name] ?? payload.cmd) : payload.cmd;
|
|
1454
|
-
try {
|
|
1455
|
-
switch (payload?.type) {
|
|
1456
|
-
case 'menu.list':
|
|
1457
|
-
return { type: 'menu.response', id, data: this.getMenuItems('owner', 'private') };
|
|
1458
|
-
case 'menu.query': {
|
|
1459
|
-
if (!cmd)
|
|
1460
|
-
return ecwebErr(id, name, 'MISSING_CMD', '缺少 name/cmd');
|
|
1461
|
-
const r = await this.execMenuQuery(cmd, agentChannelKey, agentChannelKey, userId, payload.args);
|
|
1462
|
-
return ecwebResp(id, name, r);
|
|
1463
|
-
}
|
|
1464
|
-
case 'menu.options': {
|
|
1465
|
-
if (!cmd)
|
|
1466
|
-
return ecwebErr(id, name, 'MISSING_CMD', '缺少 name/cmd');
|
|
1467
|
-
const data = await this.getSubMenuItems(cmd, agentChannelKey, agentChannelKey, userId, payload.args, ownerIdentity) ?? [];
|
|
1468
|
-
return { type: 'menu.response', id, name, data };
|
|
1469
|
-
}
|
|
1470
|
-
case 'menu.update': {
|
|
1471
|
-
if (!cmd)
|
|
1472
|
-
return ecwebErr(id, name, 'MISSING_CMD', '缺少 name/cmd');
|
|
1473
|
-
if (!payload.value)
|
|
1474
|
-
return ecwebErr(id, name, 'MISSING_VALUE', '缺少 value');
|
|
1475
|
-
const r = await this.execMenuUpdate(cmd, payload.value, agentChannelKey, agentChannelKey, userId, ownerIdentity);
|
|
1476
|
-
return ecwebResp(id, name, r);
|
|
1477
|
-
}
|
|
1478
|
-
case 'menu.action': {
|
|
1479
|
-
if (!cmd)
|
|
1480
|
-
return ecwebErr(id, name, 'MISSING_CMD', '缺少 name/cmd');
|
|
1481
|
-
if (!payload.action)
|
|
1482
|
-
return ecwebErr(id, name, 'MISSING_VALUE', '缺少 action');
|
|
1483
|
-
const r = await this.execMenuAction(cmd, payload.action, payload.args, agentChannelKey, agentChannelKey, userId, ownerIdentity);
|
|
1484
|
-
return ecwebResp(id, name, r);
|
|
1485
|
-
}
|
|
1486
|
-
default:
|
|
1487
|
-
return ecwebErr(id, name, 'NOT_SUPPORTED', `未知类型: ${payload?.type}`);
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
catch (e) {
|
|
1491
|
-
return ecwebErr(id, name, 'INTERNAL', e?.message ?? String(e));
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
/**
|
|
1495
|
-
* CLI 透传执行:spawn `node dist/cli/index.js <argv>` 子进程,捕获输出回传。
|
|
1496
|
-
* 不 in-process 调用(CLI handler 用 console.log + process.exit,spawn 行为与终端一致且隔离)。
|
|
1497
|
-
* 调用方已完成 owner 校验与白名单过滤。
|
|
1498
|
-
*/
|
|
1499
|
-
async execCliPassthrough(argv) {
|
|
1500
|
-
const { spawn } = await import('child_process');
|
|
1501
|
-
const cliEntry = path.join(getPackageRoot(), 'dist', 'cli', 'index.js');
|
|
1502
|
-
const startedAt = Date.now();
|
|
1503
|
-
return await new Promise((resolve) => {
|
|
1504
|
-
let stdout = '';
|
|
1505
|
-
let stderr = '';
|
|
1506
|
-
let total = 0;
|
|
1507
|
-
let truncated = false;
|
|
1508
|
-
let settled = false;
|
|
1509
|
-
const child = spawn('node', [cliEntry, ...argv], {
|
|
1510
|
-
env: { ...process.env, EVOLCLAW_HOME: resolvePaths().root },
|
|
1511
|
-
windowsHide: true,
|
|
1512
|
-
});
|
|
1513
|
-
const append = (buf, sink) => {
|
|
1514
|
-
if (truncated)
|
|
1515
|
-
return;
|
|
1516
|
-
const remaining = CLI_EXEC_MAX_OUTPUT - total;
|
|
1517
|
-
if (remaining <= 0) {
|
|
1518
|
-
truncated = true;
|
|
1519
|
-
return;
|
|
1520
|
-
}
|
|
1521
|
-
const chunk = buf.length > remaining ? buf.subarray(0, remaining) : buf;
|
|
1522
|
-
total += chunk.length;
|
|
1523
|
-
if (sink === 'out')
|
|
1524
|
-
stdout += chunk.toString('utf-8');
|
|
1525
|
-
else
|
|
1526
|
-
stderr += chunk.toString('utf-8');
|
|
1527
|
-
if (buf.length > remaining)
|
|
1528
|
-
truncated = true;
|
|
1529
|
-
};
|
|
1530
|
-
child.stdout?.on('data', (b) => append(b, 'out'));
|
|
1531
|
-
child.stderr?.on('data', (b) => append(b, 'err'));
|
|
1532
|
-
const timer = setTimeout(() => {
|
|
1533
|
-
if (settled)
|
|
1534
|
-
return;
|
|
1535
|
-
settled = true;
|
|
1536
|
-
try {
|
|
1537
|
-
child.kill('SIGKILL');
|
|
1538
|
-
}
|
|
1539
|
-
catch { }
|
|
1540
|
-
logger.warn(`[CommandHandler] cli exec timeout: ${argv.join(' ')}`);
|
|
1541
|
-
resolve({ error: `执行超时(${CLI_EXEC_TIMEOUT_MS / 1000}s):${argv[0]}`, code: 'TIMEOUT' });
|
|
1542
|
-
}, CLI_EXEC_TIMEOUT_MS);
|
|
1543
|
-
child.on('error', (e) => {
|
|
1544
|
-
if (settled)
|
|
1545
|
-
return;
|
|
1546
|
-
settled = true;
|
|
1547
|
-
clearTimeout(timer);
|
|
1548
|
-
resolve({ error: e?.message || String(e), code: 'INTERNAL' });
|
|
1549
|
-
});
|
|
1550
|
-
child.on('close', (exitCode) => {
|
|
1551
|
-
if (settled)
|
|
1552
|
-
return;
|
|
1553
|
-
settled = true;
|
|
1554
|
-
clearTimeout(timer);
|
|
1555
|
-
resolve({ data: {
|
|
1556
|
-
exitCode: exitCode ?? -1,
|
|
1557
|
-
stdout, stderr, truncated,
|
|
1558
|
-
durationMs: Date.now() - startedAt,
|
|
1559
|
-
} });
|
|
1560
|
-
});
|
|
1561
|
-
});
|
|
1562
|
-
}
|
|
1563
|
-
/** 把 menu.action 委派给已有 slash 命令处理逻辑,把 OutboundPayload 包成结构化结果。 */
|
|
1564
|
-
async delegateAsAction(action, slashCmd, channel, channelId, userId, opts = {}) {
|
|
1565
|
-
try {
|
|
1566
|
-
const result = await this._handleInternal(slashCmd, channel, channelId, undefined, userId, undefined, undefined, undefined, undefined, undefined, opts.overrideIdentity);
|
|
1567
|
-
if (result == null) {
|
|
1568
|
-
// null / undefined: 命令未识别或前置守卫拦截(如 idle 检查),视为失败
|
|
1569
|
-
return { error: '命令未执行(可能被前置守卫拦截)', code: 'EXEC_FAILED' };
|
|
1570
|
-
}
|
|
1571
|
-
if (typeof result !== 'object' || !('kind' in result)) {
|
|
1572
|
-
return { data: { action, success: true } };
|
|
1573
|
-
}
|
|
1574
|
-
const payload = result;
|
|
1575
|
-
if (payload.kind === 'command.error') {
|
|
1576
|
-
return { error: payload.text || '执行失败', code: 'EXEC_FAILED' };
|
|
1577
|
-
}
|
|
1578
|
-
const data = { action, success: true };
|
|
1579
|
-
if (payload.text)
|
|
1580
|
-
data.message = payload.text;
|
|
1581
|
-
if (payload.structured)
|
|
1582
|
-
data.structured = payload.structured;
|
|
1583
|
-
// 对于切换/创建类动作,附加切换后的活跃 session 信息便于客户端继续操作
|
|
1584
|
-
if (opts.enrichSession) {
|
|
1585
|
-
const newSession = await this.sessionManager.getActiveSession(channel, channelId);
|
|
1586
|
-
if (newSession) {
|
|
1587
|
-
data.session = { id: newSession.id, name: newSession.name || null };
|
|
1588
|
-
if (newSession.agentSessionId)
|
|
1589
|
-
data.session.agentSessionId = newSession.agentSessionId;
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
return { data };
|
|
1593
|
-
}
|
|
1594
|
-
catch (e) {
|
|
1595
|
-
return { error: e?.message || String(e), code: 'INTERNAL' };
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
isCommand(content) {
|
|
1599
|
-
return content === '/s' || quickCommandPrefixes.some(cmd => content.startsWith(cmd));
|
|
1600
|
-
}
|
|
1601
|
-
/**
|
|
1602
|
-
* 主命令处理入口
|
|
1603
|
-
*/
|
|
1604
|
-
async handle(content, channel, channelId, sendMessage, userId, threadId, chatType, source, messageId, selfAID) {
|
|
1605
|
-
const result = await this._handleInternal(content, channel, channelId, sendMessage, userId, threadId, chatType, source, messageId, selfAID);
|
|
1606
|
-
return result;
|
|
1607
|
-
}
|
|
1608
|
-
async _handleInternal(content, channel, channelId, sendMessage, userId, threadId, chatType, source, messageId, selfAID, overrideIdentity) {
|
|
1609
|
-
// 卡片回调的 chatType 不可靠(飞书 bot 单聊 chatId 也是 oc_ 前缀),
|
|
1610
|
-
// 不应覆盖 session 中已有的正确值
|
|
1611
|
-
if (source === 'card-trigger')
|
|
1612
|
-
chatType = undefined;
|
|
1613
|
-
// 解析身份(按实例名)
|
|
1614
|
-
const identity = overrideIdentity ?? this.sessionManager.resolveIdentity(channel, userId);
|
|
1615
|
-
const policy = this.getPolicy(channel);
|
|
1616
|
-
// 按当前会话选择 agent 后端
|
|
1617
|
-
const activeSession = await this.sessionManager.getActiveSession(channel, channelId);
|
|
1618
|
-
const agent = this.getAgent(channel, activeSession?.agentId);
|
|
1619
|
-
// 规范化命令(将别名转换为完整命令)
|
|
1620
|
-
let normalizedContent = content;
|
|
1621
|
-
for (const [alias, full] of Object.entries(aliases)) {
|
|
1622
|
-
if (content === alias || content.startsWith(alias + ' ')) {
|
|
1623
|
-
normalizedContent = content.replace(alias, full);
|
|
1624
|
-
break;
|
|
1625
|
-
}
|
|
1626
|
-
}
|
|
1627
|
-
if (normalizedContent !== content) {
|
|
1628
|
-
logger.debug(`[CommandHandler] normalized: "${content}" -> "${normalizedContent}"`);
|
|
1629
|
-
}
|
|
1630
|
-
logger.info(`[CommandHandler] handle: channel=${channel} channelId=${channelId} cmd="${normalizedContent.split(' ')[0]}" user=${userId ?? 'n/a'} role=${identity?.role ?? 'n/a'}`);
|
|
1631
|
-
// 话题内禁用部分命令
|
|
1632
|
-
if (threadId) {
|
|
1633
|
-
const threadBlocked = ['/new', '/slist', '/s', '/session', '/fork', '/del', '/baseagent'];
|
|
1634
|
-
const isBlocked = threadBlocked.some(c => normalizedContent === c || normalizedContent.startsWith(c + ' '));
|
|
1635
|
-
if (isBlocked) {
|
|
1636
|
-
return { kind: 'command.error', text: '⚠️ 话题中不支持此命令' };
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1639
|
-
// Agent-owned 通道:禁止项目切换和 agent 切换
|
|
1640
|
-
// 权限检查:区分用户级命令和管理级命令
|
|
1641
|
-
const isOwner = identity.role === 'owner';
|
|
1642
|
-
const isAdmin = identity.role === 'owner' || identity.role === 'admin';
|
|
1643
|
-
const activeChatType = activeSession?.chatType || 'private';
|
|
1644
|
-
if (normalizedContent.startsWith('/')) {
|
|
1645
|
-
// guest 在群聊和私聊中均可访问的只读命令:纯查询形态(带参写操作由各 handler 内部守卫拦截)
|
|
1646
|
-
const guestGroupCommands = [
|
|
1647
|
-
'/status', '/help', '/evolhelp', '/check', '/chatmode', '/dispatch',
|
|
1648
|
-
'/model', '/setmodel', '/effort', '/baseagent', '/perm', '/activity', '/safe', '/stop',
|
|
1649
|
-
'/resume', '/trigger',
|
|
1650
|
-
];
|
|
1651
|
-
const userCommands = activeChatType === 'group' && !isAdmin
|
|
1652
|
-
? guestGroupCommands
|
|
1653
|
-
: [
|
|
1654
|
-
...guestGroupCommands,
|
|
1655
|
-
// 私聊 guest 额外可用:会话自管理 + 私聊专属的 /rewind 历史查看
|
|
1656
|
-
'/slist', '/new', '/session', '/rename', '/name', '/del', '/s ', '/rewind',
|
|
1657
|
-
];
|
|
1658
|
-
const isUserCommand = userCommands.some(cmd => normalizedContent === cmd.trimEnd() || normalizedContent.startsWith(cmd));
|
|
1659
|
-
if (!isUserCommand && !isAdmin) {
|
|
1660
|
-
return { kind: 'command.error', text: activeChatType === 'group'
|
|
1661
|
-
? '❌ 无权限:当前群聊仅支持 /status 和 /help'
|
|
1662
|
-
: '❌ 无权限:此命令仅限管理员使用' };
|
|
1663
|
-
}
|
|
1664
|
-
}
|
|
1665
|
-
// 空闲检查:某些命令需要等待当前会话空闲
|
|
1666
|
-
// 原则:仅对"写/破坏性"形态拦截,纯读/用法提示的无参形态始终放行
|
|
1667
|
-
// - 始终需要 idle(无参即写):/compact /repair /fork /new
|
|
1668
|
-
// - 仅带参时需要 idle(无参是列表/用法):/session /baseagent /rewind
|
|
1669
|
-
// - /chatmode:在 handler 内部自行做写操作的 idle 检查
|
|
1670
|
-
// - /dispatch:在 handler 内部自行做写操作的 idle 检查
|
|
1671
|
-
// - /safe:已禁用 no-op,不再要求 idle
|
|
1672
|
-
const idleAlways = ['/compact', '/repair', '/fork', '/new'];
|
|
1673
|
-
const idleWhenArg = ['/session', '/baseagent', '/rewind'];
|
|
1674
|
-
const needsIdle = idleAlways.some(cmd => normalizedContent === cmd || normalizedContent.startsWith(cmd + ' ')) ||
|
|
1675
|
-
idleWhenArg.some(cmd => normalizedContent.startsWith(cmd + ' '));
|
|
1676
|
-
if (needsIdle) {
|
|
1677
|
-
if (threadId) {
|
|
1678
|
-
// 话题中:检查话题 session 是否在处理(不创建)
|
|
1679
|
-
const threadSession = await this.sessionManager.getThreadSession(channel, channelId, threadId);
|
|
1680
|
-
if (threadSession) {
|
|
1681
|
-
const threadAgent = this.getAgent(channel, threadSession.agentId);
|
|
1682
|
-
const isBusy = threadAgent.hasActiveStream(threadSession.id) ||
|
|
1683
|
-
this.messageQueue?.isProcessing(threadSession.id);
|
|
1684
|
-
if (isBusy) {
|
|
1685
|
-
return { kind: 'command.error', text: '⚠️ 当前正在处理消息,请稍后再试\n使用 /stop 中断当前任务后重试' };
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1689
|
-
else if (activeSession) {
|
|
1690
|
-
const isBusy = agent.hasActiveStream(activeSession.id) ||
|
|
1691
|
-
this.messageQueue?.isProcessing(activeSession.id);
|
|
1692
|
-
if (isBusy) {
|
|
1693
|
-
return { kind: 'command.error', text: '⚠️ 当前正在处理消息,请稍后再试\n使用 /stop 中断当前任务后重试' };
|
|
1694
|
-
}
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
// 检查是否以 / 开头(可能是命令)
|
|
1698
|
-
if (normalizedContent.startsWith('/')) {
|
|
1699
|
-
const inputCmd = normalizedContent.split(' ')[0];
|
|
1700
|
-
const isValidCommand = commands.some(cmd => normalizedContent.startsWith(cmd)) ||
|
|
1701
|
-
deprecatedCommands.some(cmd => normalizedContent === cmd || normalizedContent.startsWith(cmd + ' '));
|
|
1702
|
-
if (!isValidCommand) {
|
|
1703
|
-
const similar = commands.find(cmd => {
|
|
1704
|
-
const distance = levenshteinDistance(inputCmd, cmd);
|
|
1705
|
-
return distance <= 2;
|
|
1706
|
-
});
|
|
1707
|
-
if (similar) {
|
|
1708
|
-
return { kind: 'command.error', text: `❌ 未知命令: ${inputCmd}\n💡 你是不是想输入: ${similar}\n\n输入 /help 查看所有可用命令` };
|
|
1709
|
-
}
|
|
1710
|
-
else {
|
|
1711
|
-
return { kind: 'command.error', text: `❌ 未知命令: ${inputCmd}\n\n输入 /help 查看所有可用命令` };
|
|
1712
|
-
}
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1715
|
-
const isCmd = commands.some(cmd => normalizedContent.startsWith(cmd)) ||
|
|
1716
|
-
deprecatedCommands.some(cmd => normalizedContent === cmd || normalizedContent.startsWith(cmd + ' '));
|
|
1717
|
-
if (!isCmd)
|
|
1718
|
-
return undefined;
|
|
1719
|
-
// /help 命令不需要会话
|
|
1720
|
-
if (normalizedContent === '/help') {
|
|
1721
|
-
if (!isAdmin && activeChatType === 'group') {
|
|
1722
|
-
const lines = [
|
|
1723
|
-
'可用命令:',
|
|
1724
|
-
'',
|
|
1725
|
-
'其他:',
|
|
1726
|
-
' /status - 显示会话状态',
|
|
1727
|
-
' /check - 检查渠道健康',
|
|
1728
|
-
' /help - 显示此帮助信息',
|
|
1729
|
-
];
|
|
1730
|
-
return { kind: 'command.result', text: lines.join('\n') };
|
|
1731
|
-
}
|
|
1732
|
-
if (!isAdmin) {
|
|
1733
|
-
const lines = [
|
|
1734
|
-
'可用命令:',
|
|
1735
|
-
'',
|
|
1736
|
-
'🔄 会话管理:',
|
|
1737
|
-
' /new [名称] - 创建新会话(清空历史请用此命令,可选命名)',
|
|
1738
|
-
' /s [cli|名称|序号|uuid] - 列出或切换会话(cli 查看未导入的 CLI 会话)',
|
|
1739
|
-
' /name <新名称> - 重命名当前会话',
|
|
1740
|
-
' /del <名称> - 删除指定会话(仅解绑,不删除文件)',
|
|
1741
|
-
' /status - 显示会话状态',
|
|
1742
|
-
' /check - 检查渠道健康',
|
|
1743
|
-
'',
|
|
1744
|
-
'❓ 帮助:',
|
|
1745
|
-
' /help - 显示此帮助信息',
|
|
1746
|
-
];
|
|
1747
|
-
return { kind: 'command.result', text: lines.join('\n') };
|
|
1748
|
-
}
|
|
1749
|
-
// admin+ 基础命令
|
|
1750
|
-
const lines = [
|
|
1751
|
-
'可用命令:',
|
|
1752
|
-
'',
|
|
1753
|
-
'📁 项目:',
|
|
1754
|
-
' /pwd - 显示当前项目路径',
|
|
1755
|
-
'',
|
|
1756
|
-
'🔄 会话管理:',
|
|
1757
|
-
' /new [名称] - 创建新会话(清空历史请用此命令,可选命名)',
|
|
1758
|
-
' /s [cli|名称|序号|uuid] - 列出或切换会话(cli 查看未导入的 CLI 会话)',
|
|
1759
|
-
' /name <新名称> - 重命名当前会话',
|
|
1760
|
-
' /del <名称> - 删除指定会话(仅解绑,不删除文件)',
|
|
1761
|
-
' /fork [名称] - 分支当前会话(从当前对话点创建分支)',
|
|
1762
|
-
' /rewind [N] [chat|file|all] - 查看历史/撤销指定轮次(别名: /rw)',
|
|
1763
|
-
' /compact - 压缩会话上下文(减少 token 用量)',
|
|
1764
|
-
'',
|
|
1765
|
-
'🤖 Agent 与模型:',
|
|
1766
|
-
' /baseagent [name] - 查看或切换 Agent 后端(别名: /base)',
|
|
1767
|
-
' /model [model] - 查看或切换模型',
|
|
1768
|
-
' /effort [level] - 查看或切换推理强度',
|
|
1769
|
-
'',
|
|
1770
|
-
'💬 聊天设置:',
|
|
1771
|
-
' /activity [all|dm|owner|none] - 查看/控制中间输出显示模式',
|
|
1772
|
-
' /chatmode [interactive|proactive] - 查看/切换会话模式(被动响应或主动推进)',
|
|
1773
|
-
' /dispatch [mention|broadcast] - 查看/切换群聊分发模式(仅@响应或广播响应,仅群聊)',
|
|
1774
|
-
'',
|
|
1775
|
-
'🔐 权限管理:',
|
|
1776
|
-
' /perm - 查看当前权限模式',
|
|
1777
|
-
...(isOwner ? [` /perm <${PERMISSION_MODE_USAGE}> - 切换权限模式`] : []),
|
|
1778
|
-
' /perm allow|always|deny - 审批权限请求',
|
|
1779
|
-
'',
|
|
1780
|
-
'🛠️ 运维:',
|
|
1781
|
-
' /status - 显示会话状态',
|
|
1782
|
-
' /stop - 中断当前任务',
|
|
1783
|
-
' /check - 检查渠道状态',
|
|
1784
|
-
...(isAdmin ? [
|
|
1785
|
-
' /restart - 重启服务(owner only)',
|
|
1786
|
-
] : []),
|
|
1787
|
-
...(isOwner ? [
|
|
1788
|
-
' /restart - 重启服务',
|
|
1789
|
-
] : []),
|
|
1790
|
-
...(isOwner ? [
|
|
1791
|
-
'',
|
|
1792
|
-
'🧰 工具:',
|
|
1793
|
-
' /file [channel] <path> - 发送项目内文件',
|
|
1794
|
-
] : []),
|
|
1795
|
-
'',
|
|
1796
|
-
'❓ 帮助:',
|
|
1797
|
-
' /help - 显示此帮助信息',
|
|
1798
|
-
];
|
|
1799
|
-
return { kind: 'command.result', text: lines.join('\n') };
|
|
1800
|
-
}
|
|
1801
|
-
// /evolhelp 命令:返回 JSON 格式的命令列表(供程序解析)
|
|
1802
|
-
if (normalizedContent === '/evolhelp') {
|
|
1803
|
-
const cmds = [];
|
|
1804
|
-
// 项目
|
|
1805
|
-
cmds.push({ command: '/pwd', description: '显示当前项目路径', category: '项目', roles: ['admin', 'owner'] });
|
|
1806
|
-
// 会话管理
|
|
1807
|
-
cmds.push({ command: '/new', args: '[名称]', description: '创建新会话(清空历史请用此命令,可选命名)', category: '会话管理', roles: ['guest', 'admin', 'owner'] });
|
|
1808
|
-
cmds.push({ command: '/s', aliases: ['/session', '/slist'], args: '[cli|名称|序号|uuid]', description: '列出或切换会话', category: '会话管理', roles: ['guest', 'admin', 'owner'] });
|
|
1809
|
-
cmds.push({ command: '/name', aliases: ['/rename'], args: '<新名称>', description: '重命名当前会话', category: '会话管理', roles: ['guest', 'admin', 'owner'] });
|
|
1810
|
-
cmds.push({ command: '/del', args: '<名称>', description: '删除指定会话(仅解绑,不删除文件)', category: '会话管理', roles: ['guest', 'admin', 'owner'] });
|
|
1811
|
-
if (isAdmin) {
|
|
1812
|
-
cmds.push({ command: '/fork', args: '[名称]', description: '分支当前会话(从当前对话点创建分支)', category: '会话管理', roles: ['admin', 'owner'] });
|
|
1813
|
-
cmds.push({ command: '/rewind', aliases: ['/rw'], args: '[N] [chat|file|all]', description: '查看历史/撤销指定轮次', category: '会话管理', roles: ['admin', 'owner'] });
|
|
1814
|
-
cmds.push({ command: '/compact', description: '压缩会话上下文(减少 token 用量)', category: '会话管理', roles: ['admin', 'owner'] });
|
|
1815
|
-
}
|
|
1816
|
-
// Agent 与模型
|
|
1817
|
-
if (isAdmin) {
|
|
1818
|
-
cmds.push({ command: '/baseagent', aliases: ['/base'], args: '[name]', description: '查看或切换 Agent 后端', category: 'Agent 与模型', roles: ['admin', 'owner'] });
|
|
1819
|
-
cmds.push({ command: '/model', args: '[model]', description: '查看或切换模型', category: 'Agent 与模型', roles: ['admin', 'owner'] });
|
|
1820
|
-
cmds.push({ command: '/effort', args: '[level]', description: '查看或切换推理强度', category: 'Agent 与模型', roles: ['admin', 'owner'] });
|
|
1821
|
-
}
|
|
1822
|
-
// 权限管理
|
|
1823
|
-
if (isAdmin) {
|
|
1824
|
-
cmds.push({ command: '/perm', args: isOwner ? `<${PERMISSION_MODE_USAGE}>` : undefined, description: '查看当前权限模式', category: '权限管理', roles: ['admin', 'owner'] });
|
|
1825
|
-
cmds.push({ command: '/perm', args: 'allow|always|deny', description: '审批权限请求', category: '权限管理', roles: ['admin', 'owner'] });
|
|
1826
|
-
}
|
|
1827
|
-
// 运维
|
|
1828
|
-
cmds.push({ command: '/status', description: '显示会话状态', category: '运维', roles: ['guest', 'admin', 'owner'] });
|
|
1829
|
-
cmds.push({ command: '/stop', description: '中断当前任务', category: '运维', roles: ['admin', 'owner'] });
|
|
1830
|
-
cmds.push({ command: '/check', description: '检查渠道状态', category: '运维', roles: ['guest', 'admin', 'owner'] });
|
|
1831
|
-
if (isAdmin) {
|
|
1832
|
-
cmds.push({ command: '/activity', args: '[all|dm|owner|none]', description: '查看/控制中间输出显示模式', category: '聊天设置', roles: ['admin', 'owner'] });
|
|
1833
|
-
}
|
|
1834
|
-
if (isOwner) {
|
|
1835
|
-
cmds.push({ command: '/restart', description: '重启服务', category: '运维', roles: ['owner'] });
|
|
1836
|
-
cmds.push({ command: '/file', args: '[channel] <path>', description: '发送项目内文件', category: '工具', roles: ['owner'] });
|
|
1837
|
-
}
|
|
1838
|
-
// 聊天设置
|
|
1839
|
-
if (isAdmin) {
|
|
1840
|
-
cmds.push({ command: '/chatmode', args: '[interactive|proactive]', description: '查看/切换会话模式(被动响应或主动推进)', category: '聊天设置', roles: ['admin', 'owner'] });
|
|
1841
|
-
cmds.push({ command: '/dispatch', args: '[mention|broadcast]', description: '查看/切换群聊分发模式(仅@响应或广播响应)', category: '聊天设置', roles: ['admin', 'owner'] });
|
|
1842
|
-
}
|
|
1843
|
-
// 交互
|
|
1844
|
-
cmds.push({ command: '/ask', args: '<选项>', description: '回答 Agent 的交互式问题', category: '运维', roles: ['guest', 'admin', 'owner'] });
|
|
1845
|
-
// 帮助
|
|
1846
|
-
cmds.push({ command: '/help', description: '显示帮助信息', category: '帮助', roles: ['guest', 'admin', 'owner'] });
|
|
1847
|
-
const categories = [...new Set(cmds.map(c => c.category))];
|
|
1848
|
-
return { kind: 'command.result', text: JSON.stringify({ commands: cmds, categories }) };
|
|
1849
|
-
}
|
|
1850
|
-
// /perm 命令:权限模式切换 + 权限审批(快速路径,不进入消息队列)
|
|
1851
|
-
if (normalizedContent.startsWith('/perm')) {
|
|
1852
|
-
const args = normalizedContent.slice(5).trim();
|
|
1853
|
-
// 先获取正确的 session 和 agent(话题可能用不同 agent)
|
|
1854
|
-
const permResult = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
1855
|
-
if ('error' in permResult)
|
|
1856
|
-
return { kind: 'command.result', text: permResult.error };
|
|
1857
|
-
const { session: permSession } = permResult;
|
|
1858
|
-
const permAgent = this.getAgent(channel, permSession.agentId);
|
|
1859
|
-
// /perm(无参数):显示当前模式和可选模式
|
|
1860
|
-
if (!args) {
|
|
1861
|
-
if (!hasPermissionController(permAgent)) {
|
|
1862
|
-
return { kind: 'command.error', text: '❌ 权限控制不可用' };
|
|
1863
|
-
}
|
|
1864
|
-
const currentMode = permSession.metadata?.permissionMode ?? DEFAULT_PERMISSION_MODE;
|
|
1865
|
-
const modes = permAgent.listModes();
|
|
1866
|
-
// 尝试发送 CommandCard 卡片
|
|
1867
|
-
{
|
|
1868
|
-
const availableModes = modes.filter(m => m.available);
|
|
1869
|
-
const interaction = {
|
|
1870
|
-
type: 'interaction',
|
|
1871
|
-
id: `perm-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`,
|
|
1872
|
-
channelId,
|
|
1873
|
-
sessionId: permSession.id,
|
|
1874
|
-
initiatorId: userId,
|
|
1875
|
-
kind: {
|
|
1876
|
-
kind: 'command-card',
|
|
1877
|
-
title: '🔐 权限模式',
|
|
1878
|
-
body: availableModes.map(m => `${m.key === currentMode ? '✓' : '•'} **${m.key}** (${m.nameZh}) - ${m.description}`).join('\n'),
|
|
1879
|
-
buttons: availableModes.map(m => ({
|
|
1880
|
-
label: m.key === currentMode ? `✓ ${m.key}` : m.key,
|
|
1881
|
-
command: `/perm ${m.key}`,
|
|
1882
|
-
style: (m.key === currentMode ? 'primary' : 'default'),
|
|
1883
|
-
disabled: m.key === currentMode,
|
|
1884
|
-
})),
|
|
1885
|
-
},
|
|
1886
|
-
};
|
|
1887
|
-
const replyCtx = this.getReplyContext(permSession);
|
|
1888
|
-
const cardResult = await this.sendCommandCard({ channel, channelId, interaction, replyCtx, canWrite: isOwner });
|
|
1889
|
-
if (cardResult === null)
|
|
1890
|
-
return null;
|
|
1891
|
-
return { kind: 'command.result', text: cardResult };
|
|
1892
|
-
}
|
|
1893
|
-
// 降级:文本
|
|
1894
|
-
const modeList = modes.map(m => {
|
|
1895
|
-
const prefix = m.key === currentMode ? '✓' : ' ';
|
|
1896
|
-
const suffix = m.available ? '' : ' ⚠️ 不可用';
|
|
1897
|
-
return ` ${prefix} ${m.key} (${m.nameZh}) - ${m.description}${suffix}`;
|
|
1898
|
-
}).join('\n');
|
|
1899
|
-
if (isOwner) {
|
|
1900
|
-
return { kind: 'command.result', text: `权限模式: ${currentMode}\n\n${modeList}\n\n用法: /perm <模式> 或 allow|always|deny` };
|
|
1901
|
-
}
|
|
1902
|
-
return { kind: 'command.result', text: `当前权限模式: ${currentMode}` };
|
|
1903
|
-
}
|
|
1904
|
-
const parts = args.split(/\s+/);
|
|
1905
|
-
// /perm <mode> 或 /perm allow|always|deny:切换模式 / 快捷审批
|
|
1906
|
-
if (parts.length === 1) {
|
|
1907
|
-
const arg = parts[0];
|
|
1908
|
-
// /perm allow|always|deny:快捷审批
|
|
1909
|
-
// 优先走 InteractionRouter fallback(统一降级路径)
|
|
1910
|
-
if (arg === 'allow' || arg === 'always' || arg === 'deny') {
|
|
1911
|
-
const fb = await this.handleInteractionFallback('perm', arg, permSession.id, userId);
|
|
1912
|
-
if (fb.matched)
|
|
1913
|
-
return { kind: 'command.result', text: fb.result ?? '✓ 已回答' };
|
|
1914
|
-
// fallback 不命中:走 permissionGateway 直接审批(兼容旧路径)
|
|
1915
|
-
if (!this.permissionGateway) {
|
|
1916
|
-
return { kind: 'command.error', text: '❌ 权限审批未启用' };
|
|
1917
|
-
}
|
|
1918
|
-
const pendingIds = this.permissionGateway.getPendingRequests(permSession.id);
|
|
1919
|
-
if (pendingIds.length === 0) {
|
|
1920
|
-
return { kind: 'command.error', text: '❌ 当前没有待审批的权限请求' };
|
|
1921
|
-
}
|
|
1922
|
-
if (pendingIds.length > 1) {
|
|
1923
|
-
return { kind: 'command.error', text: `❌ 当前有 ${pendingIds.length} 个待审批请求,请指定 requestId:\n${pendingIds.map(id => ` /perm ${id} ${arg}`).join('\n')}` };
|
|
1924
|
-
}
|
|
1925
|
-
const requestId = pendingIds[0];
|
|
1926
|
-
const decision = arg;
|
|
1927
|
-
this.permissionGateway.resolvePermission(permSession.id, requestId, decision);
|
|
1928
|
-
const labels = {
|
|
1929
|
-
allow: '✓ 已授权(本次),继续执行……',
|
|
1930
|
-
always: '✓ 已授权(始终允许该工具),继续执行……',
|
|
1931
|
-
deny: '✓ 已拒绝'
|
|
1932
|
-
};
|
|
1933
|
-
return { kind: 'command.result', text: labels[decision] };
|
|
1934
|
-
}
|
|
1935
|
-
// /perm <mode>:切换权限模式
|
|
1936
|
-
if (hasPermissionController(permAgent)) {
|
|
1937
|
-
const modes = permAgent.listModes();
|
|
1938
|
-
const matched = modes.find(m => m.key === arg);
|
|
1939
|
-
if (matched) {
|
|
1940
|
-
if (!matched.available) {
|
|
1941
|
-
return { kind: 'command.error', text: `❌ ${matched.key} 模式当前不可用:${matched.unavailableReason}` };
|
|
1942
|
-
}
|
|
1943
|
-
// guest 和 admin 用户不能切换权限模式(仅 owner)
|
|
1944
|
-
if (!isOwner) {
|
|
1945
|
-
return { kind: 'command.error', text: '❌ 权限模式切换仅限 owner' };
|
|
1946
|
-
}
|
|
1947
|
-
const metadata = permSession.metadata || {};
|
|
1948
|
-
metadata.permissionMode = arg;
|
|
1949
|
-
await this.sessionManager.updateSession(permSession.id, { metadata });
|
|
1950
|
-
if (source === 'card-trigger')
|
|
1951
|
-
return null;
|
|
1952
|
-
return { kind: 'command.result', text: `✓ 权限模式已切换为: ${matched.key} (${matched.nameZh})\n${matched.description}` };
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
// 不是已知模式名也不是 allow/deny
|
|
1956
|
-
const modeKeys = hasPermissionController(permAgent) ? permAgent.listModes().map(m => m.key).join('|') : PERMISSION_MODE_USAGE;
|
|
1957
|
-
return { kind: 'command.error', text: `❌ 未知参数: ${arg}\n用法: /perm <${modeKeys}> 或 /perm allow|always|deny` };
|
|
1958
|
-
}
|
|
1959
|
-
// 双参数不再支持,提示正确用法
|
|
1960
|
-
const allModeKeys = hasPermissionController(permAgent) ? permAgent.listModes().map(m => m.key).join('|') : PERMISSION_MODE_USAGE;
|
|
1961
|
-
return { kind: 'command.error', text: `❌ 未知参数: ${args}\n用法: /perm <${allModeKeys}> 或 /perm allow|always|deny` };
|
|
1962
|
-
}
|
|
1963
|
-
// /ask 命令:回答 AskUserQuestion / ExitPlanMode 的交互式问题
|
|
1964
|
-
if (normalizedContent.startsWith('/ask')) {
|
|
1965
|
-
const args = normalizedContent.slice(4).trim();
|
|
1966
|
-
if (!args) {
|
|
1967
|
-
const askResult = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
1968
|
-
if ('error' in askResult)
|
|
1969
|
-
return { kind: 'command.result', text: askResult.error };
|
|
1970
|
-
const pendingIds = this.interactionRouter?.getPending(askResult.session.id) || [];
|
|
1971
|
-
if (pendingIds.length === 0)
|
|
1972
|
-
return { kind: 'command.result', text: '当前没有待回答的问题' };
|
|
1973
|
-
return { kind: 'command.result', text: `当前有 ${pendingIds.length} 个待回答问题,请回复 /ask <选项>` };
|
|
1974
|
-
}
|
|
1975
|
-
const askResult = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
1976
|
-
if ('error' in askResult)
|
|
1977
|
-
return { kind: 'command.result', text: askResult.error };
|
|
1978
|
-
const fb = await this.handleInteractionFallback('ask', args, askResult.session.id, userId);
|
|
1979
|
-
if (fb.matched)
|
|
1980
|
-
return { kind: 'command.result', text: fb.result ?? '✓ 已回答' };
|
|
1981
|
-
return { kind: 'command.error', text: '❌ 当前没有待回答的问题' };
|
|
1982
|
-
}
|
|
1983
|
-
// /resume 命令:返回当前项目的 Claude 会话记录(JSON)
|
|
1984
|
-
if (normalizedContent === '/resume' || normalizedContent.startsWith('/resume ')) {
|
|
1985
|
-
const resumeResult = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
1986
|
-
if ('error' in resumeResult)
|
|
1987
|
-
return { kind: 'command.result', text: resumeResult.error };
|
|
1988
|
-
const { session: resumeSession } = resumeResult;
|
|
1989
|
-
try {
|
|
1990
|
-
const { encodePath } = await import('../utils/cross-platform.js');
|
|
1991
|
-
const homeDir = os.homedir();
|
|
1992
|
-
const encodedPath = encodePath(resumeSession.projectPath);
|
|
1993
|
-
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
|
1994
|
-
if (!fs.existsSync(projectDir)) {
|
|
1995
|
-
return { kind: 'command.error', text: '❌ 未找到 Claude 会话记录目录' };
|
|
1996
|
-
}
|
|
1997
|
-
const jsonlFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
|
|
1998
|
-
if (jsonlFiles.length === 0) {
|
|
1999
|
-
return { kind: 'command.error', text: '❌ 当前项目没有 Claude 会话记录' };
|
|
2000
|
-
}
|
|
2001
|
-
const sessions = [];
|
|
2002
|
-
for (const file of jsonlFiles) {
|
|
2003
|
-
const filePath = path.join(projectDir, file);
|
|
2004
|
-
const sessionId = file.replace('.jsonl', '');
|
|
2005
|
-
let lastTimestamp = '';
|
|
2006
|
-
let firstUserMessage = '';
|
|
2007
|
-
let model = '';
|
|
2008
|
-
let branch = '';
|
|
2009
|
-
let turns = 0;
|
|
2010
|
-
try {
|
|
2011
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
2012
|
-
const lines = content.split('\n').filter(l => l.trim());
|
|
2013
|
-
for (const line of lines) {
|
|
2014
|
-
const event = JSON.parse(line);
|
|
2015
|
-
if (event.timestamp && event.timestamp > lastTimestamp) {
|
|
2016
|
-
lastTimestamp = event.timestamp;
|
|
2017
|
-
}
|
|
2018
|
-
if (event.gitBranch && !branch) {
|
|
2019
|
-
branch = event.gitBranch;
|
|
2020
|
-
}
|
|
2021
|
-
if (event.type === 'user' && event.message?.role === 'user') {
|
|
2022
|
-
const msgContent = event.message.content;
|
|
2023
|
-
const isToolResult = Array.isArray(msgContent) && msgContent.every((c) => c.type === 'tool_result');
|
|
2024
|
-
if (!isToolResult) {
|
|
2025
|
-
turns++;
|
|
2026
|
-
if (!firstUserMessage) {
|
|
2027
|
-
if (typeof msgContent === 'string') {
|
|
2028
|
-
firstUserMessage = msgContent.slice(0, 100);
|
|
2029
|
-
}
|
|
2030
|
-
else if (Array.isArray(msgContent)) {
|
|
2031
|
-
const textBlock = msgContent.find((c) => c.type === 'text');
|
|
2032
|
-
if (textBlock?.text) {
|
|
2033
|
-
firstUserMessage = textBlock.text.slice(0, 100);
|
|
2034
|
-
}
|
|
2035
|
-
}
|
|
2036
|
-
}
|
|
2037
|
-
}
|
|
2038
|
-
}
|
|
2039
|
-
if (event.type === 'assistant' && event.message?.model && !model) {
|
|
2040
|
-
model = event.message.model;
|
|
2041
|
-
}
|
|
2042
|
-
}
|
|
2043
|
-
}
|
|
2044
|
-
catch {
|
|
2045
|
-
continue;
|
|
2046
|
-
}
|
|
2047
|
-
if (!lastTimestamp)
|
|
2048
|
-
continue;
|
|
2049
|
-
sessions.push({
|
|
2050
|
-
sessionId,
|
|
2051
|
-
lastMessageTime: lastTimestamp,
|
|
2052
|
-
firstUserMessage: firstUserMessage || '(无消息)',
|
|
2053
|
-
model: model || 'unknown',
|
|
2054
|
-
turns,
|
|
2055
|
-
branch: branch || 'unknown',
|
|
2056
|
-
});
|
|
2057
|
-
}
|
|
2058
|
-
sessions.sort((a, b) => b.lastMessageTime.localeCompare(a.lastMessageTime));
|
|
2059
|
-
return { kind: 'command.result', text: JSON.stringify(sessions, null, 2) };
|
|
2060
|
-
}
|
|
2061
|
-
catch (error) {
|
|
2062
|
-
logger.error('[CommandHandler] /resume failed:', error);
|
|
2063
|
-
return { kind: 'command.error', text: `❌ 读取会话记录失败: ${error instanceof Error ? error.message : '未知错误'}` };
|
|
2064
|
-
}
|
|
2065
|
-
}
|
|
2066
|
-
// /baseagent 命令:查看或切换 Agent 后端
|
|
2067
|
-
if (normalizedContent === '/baseagent' || normalizedContent.startsWith('/baseagent ')) {
|
|
2068
|
-
const args = normalizedContent.slice(10).trim();
|
|
2069
|
-
// 切换(带参)需权限:群聊 owner only,私聊 admin+;无参查询对所有人放开
|
|
2070
|
-
if (args && (activeChatType === 'group' ? !isOwner : !isAdmin)) {
|
|
2071
|
-
return { kind: 'command.error', text: '❌ 无权限:此命令仅限管理员使用' };
|
|
2072
|
-
}
|
|
2073
|
-
const available = this.getAvailableBaseagents(channel);
|
|
2074
|
-
if (!args) {
|
|
2075
|
-
// currentAgent: 当前 session 的 baseagent,或该 channel 所属 evolagent 的 baseagent
|
|
2076
|
-
const currentAgent = activeSession?.agentId
|
|
2077
|
-
|| this.agentRegistry?.resolveByChannel(channel)?.baseagent
|
|
2078
|
-
|| this.parseDefaultBaseagent();
|
|
2079
|
-
// 尝试发送 CommandCard 卡片
|
|
2080
|
-
if (this.interactionRouter && available.length > 1) {
|
|
2081
|
-
const interaction = {
|
|
2082
|
-
type: 'interaction',
|
|
2083
|
-
id: `agent-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`,
|
|
2084
|
-
channelId,
|
|
2085
|
-
sessionId: activeSession?.id || `agent-${Date.now()}`,
|
|
2086
|
-
initiatorId: userId,
|
|
2087
|
-
kind: {
|
|
2088
|
-
kind: 'command-card',
|
|
2089
|
-
title: '🔌 切换 Agent',
|
|
2090
|
-
buttons: available.map(a => ({
|
|
2091
|
-
label: a === currentAgent ? `✓ ${a}` : a,
|
|
2092
|
-
command: `/baseagent ${a}`,
|
|
2093
|
-
style: (a === currentAgent ? 'primary' : 'default'),
|
|
2094
|
-
disabled: a === currentAgent,
|
|
2095
|
-
})),
|
|
2096
|
-
},
|
|
2097
|
-
};
|
|
2098
|
-
const replyCtx = activeSession ? this.getReplyContext(activeSession) : undefined;
|
|
2099
|
-
const cardResult = await this.sendCommandCard({ channel, channelId, interaction, replyCtx, canWrite: activeChatType === 'group' ? isOwner : isAdmin });
|
|
2100
|
-
if (cardResult === null)
|
|
2101
|
-
return null;
|
|
2102
|
-
return { kind: 'command.result', text: cardResult };
|
|
2103
|
-
}
|
|
2104
|
-
// 降级:文本
|
|
2105
|
-
const list = available.map(a => `${a === currentAgent ? ' ✓' : ' '} ${a}`).join('\n');
|
|
2106
|
-
const canSwitchAgent = activeChatType === 'group' ? isOwner : isAdmin;
|
|
2107
|
-
if (canSwitchAgent) {
|
|
2108
|
-
return { kind: 'command.result', text: `当前 Agent: ${currentAgent}\n\n可用:\n${list}\n用法: /baseagent <name>` };
|
|
2109
|
-
}
|
|
2110
|
-
return { kind: 'command.result', text: `当前 Agent: ${currentAgent}` };
|
|
2111
|
-
}
|
|
2112
|
-
if (!available.includes(args)) {
|
|
2113
|
-
return { kind: 'command.error', text: `❌ 未知 Agent: ${args}\n可用: ${available.join(', ')}` };
|
|
2114
|
-
}
|
|
2115
|
-
const result = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
2116
|
-
if ('error' in result)
|
|
2117
|
-
return { kind: 'command.error', text: result.error };
|
|
2118
|
-
const { session } = result;
|
|
2119
|
-
// 取消原会话的 pending 权限请求和交互卡片
|
|
2120
|
-
if (this.permissionGateway) {
|
|
2121
|
-
this.permissionGateway.cancelAll(session.id);
|
|
2122
|
-
}
|
|
2123
|
-
if (this.interactionRouter) {
|
|
2124
|
-
this.interactionRouter.cancelAll(session.id);
|
|
2125
|
-
}
|
|
2126
|
-
// 切换到目标 agent(恢复已有会话或创建新会话)
|
|
2127
|
-
const newSession = await this.sessionManager.switchAgent(channel, channelId, session.projectPath, args);
|
|
2128
|
-
const hasExistingSession = newSession.agentSessionId ? '(恢复已有会话)' : '(新建会话)';
|
|
2129
|
-
const projectName = this.getProjectName(session.projectPath);
|
|
2130
|
-
let agentSwitchResponse = `✓ 已切换 Agent: ${args}\n 项目: ${projectName}\n 会话: ${displaySessionTitle(newSession.name, '(未命名)')}\n ${hasExistingSession}`;
|
|
2131
|
-
if (source === 'card-trigger')
|
|
2132
|
-
return null;
|
|
2133
|
-
return { kind: 'command.result', text: agentSwitchResponse };
|
|
2134
|
-
}
|
|
2135
|
-
// /setmodel 命令:返回 JSON 格式的模型列表(供程序解析)
|
|
2136
|
-
if (normalizedContent === '/setmodel' || normalizedContent.startsWith('/setmodel ')) {
|
|
2137
|
-
const setmodelResult = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
2138
|
-
if ('error' in setmodelResult)
|
|
2139
|
-
return { kind: 'command.result', text: setmodelResult.error };
|
|
2140
|
-
const { session: setmodelSession } = setmodelResult;
|
|
2141
|
-
const setmodelAgent = this.getAgent(channel, setmodelSession.agentId);
|
|
2142
|
-
const currentModel = hasModelSwitcher(setmodelAgent) ? setmodelAgent.getModel() : setmodelAgent.name;
|
|
2143
|
-
const efforts = getAvailableEfforts(setmodelAgent, currentModel);
|
|
2144
|
-
const currentEffort = setmodelAgent.getEffort?.() || 'auto';
|
|
2145
|
-
const now = Math.floor(Date.now() / 1000);
|
|
2146
|
-
const modelIds = hasModelSwitcher(setmodelAgent) ? await setmodelAgent.listModels() : [];
|
|
2147
|
-
const modelListData = {
|
|
2148
|
-
object: 'list',
|
|
2149
|
-
data: modelIds.map(id => ({ id, object: 'model', created: now, owned_by: setmodelAgent.name === 'codex' ? 'openai' : 'anthropic' })),
|
|
2150
|
-
};
|
|
2151
|
-
return { kind: 'command.result', text: JSON.stringify({
|
|
2152
|
-
current_model: currentModel,
|
|
2153
|
-
current_effort: currentEffort,
|
|
2154
|
-
available_efforts: efforts,
|
|
2155
|
-
models: modelListData,
|
|
2156
|
-
}, null, 2) };
|
|
2157
|
-
}
|
|
2158
|
-
// /model 命令:查看或切换模型/推理强度
|
|
2159
|
-
if (normalizedContent.startsWith('/model')) {
|
|
2160
|
-
const args = normalizedContent.slice(6).trim();
|
|
2161
|
-
// 获取当前会话(话题会话可能绑定不同 agent)
|
|
2162
|
-
const modelResult = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
2163
|
-
if ('error' in modelResult)
|
|
2164
|
-
return { kind: 'command.result', text: modelResult.error };
|
|
2165
|
-
const { session: modelSession } = modelResult;
|
|
2166
|
-
const modelAgent = this.getAgent(channel, modelSession.agentId);
|
|
2167
|
-
const models = hasModelSwitcher(modelAgent) ? await modelAgent.listModels() : [];
|
|
2168
|
-
if (!args) {
|
|
2169
|
-
const currentModel = hasModelSwitcher(modelAgent) ? modelAgent.getModel() : modelAgent.name;
|
|
2170
|
-
const efforts = getAvailableEfforts(modelAgent, currentModel);
|
|
2171
|
-
const currentEffort = modelAgent.getEffort?.() || 'auto';
|
|
2172
|
-
// 尝试发送 CommandCard 卡片
|
|
2173
|
-
if (this.interactionRouter && models.length > 0) {
|
|
2174
|
-
const interaction = {
|
|
2175
|
-
type: 'interaction',
|
|
2176
|
-
id: `model-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`,
|
|
2177
|
-
channelId,
|
|
2178
|
-
sessionId: modelSession.id,
|
|
2179
|
-
initiatorId: userId,
|
|
2180
|
-
kind: {
|
|
2181
|
-
kind: 'command-card',
|
|
2182
|
-
title: '🤖 切换模型',
|
|
2183
|
-
buttons: models.map((m) => {
|
|
2184
|
-
const display = modelDisplayLabel(modelAgent, m);
|
|
2185
|
-
return {
|
|
2186
|
-
label: m === currentModel ? `✓ ${display}` : display,
|
|
2187
|
-
command: `/model ${m}`,
|
|
2188
|
-
style: (m === currentModel ? 'primary' : 'default'),
|
|
2189
|
-
disabled: m === currentModel,
|
|
2190
|
-
};
|
|
2191
|
-
}),
|
|
2192
|
-
},
|
|
2193
|
-
};
|
|
2194
|
-
const replyCtx = this.getReplyContext(modelSession);
|
|
2195
|
-
const cardResult = await this.sendCommandCard({ channel, channelId, interaction, replyCtx, canWrite: isAdmin });
|
|
2196
|
-
if (cardResult === null)
|
|
2197
|
-
return null;
|
|
2198
|
-
return { kind: 'command.result', text: cardResult };
|
|
2199
|
-
}
|
|
2200
|
-
// 降级:文本
|
|
2201
|
-
const modelList = models.map((m) => ` ${m === currentModel ? '✓' : ' '} ${modelDisplayLabel(modelAgent, m)}`).join('\n');
|
|
2202
|
-
const effortHint = efforts.length > 0
|
|
2203
|
-
? `\n推理强度: ${currentEffort === 'auto' ? 'auto (SDK默认)' : currentEffort} (使用 /effort 调整)`
|
|
2204
|
-
: '';
|
|
2205
|
-
if (isAdmin) {
|
|
2206
|
-
return { kind: 'command.result', text: `当前模型: ${modelDisplayLabel(modelAgent, currentModel)}${effortHint}\n\n可用模型:\n${modelList}\n\n用法: /model <模型>` };
|
|
2207
|
-
}
|
|
2208
|
-
return { kind: 'command.result', text: `当前模型: ${modelDisplayLabel(modelAgent, currentModel)}${effortHint}` };
|
|
2209
|
-
}
|
|
2210
|
-
// 带参(切换/调整)需 admin+;无参查询已在上方返回
|
|
2211
|
-
if (!isAdmin)
|
|
2212
|
-
return { kind: 'command.error', text: '❌ 无权限:切换模型仅限管理员使用' };
|
|
2213
|
-
const parts = args.split(/\s+/);
|
|
2214
|
-
let newModel;
|
|
2215
|
-
let newEffort;
|
|
2216
|
-
if (parts.length === 1) {
|
|
2217
|
-
const arg = parts[0];
|
|
2218
|
-
const currentModel = hasModelSwitcher(modelAgent) ? modelAgent.getModel() : modelAgent.name;
|
|
2219
|
-
const efforts = getAvailableEfforts(modelAgent, currentModel);
|
|
2220
|
-
// effort 相关参数统一转发到 /effort
|
|
2221
|
-
if (efforts.includes(arg) || arg === 'auto') {
|
|
2222
|
-
const delegated = await this.handle(`/effort ${arg}`, channel, channelId, undefined, userId, threadId);
|
|
2223
|
-
return typeof delegated === 'string' ? { kind: 'command.result', text: delegated } : delegated;
|
|
2224
|
-
}
|
|
2225
|
-
else if (allEfforts.includes(arg)) {
|
|
2226
|
-
return { kind: 'command.error', text: `⚠️ 请使用 /effort ${arg} 调整推理强度` };
|
|
2227
|
-
}
|
|
2228
|
-
else {
|
|
2229
|
-
const resolvedArg = hasModelSwitcher(modelAgent) ? (modelAgent.resolveModelId?.(arg) ?? arg) : arg;
|
|
2230
|
-
if (models.includes(resolvedArg)) {
|
|
2231
|
-
newModel = resolvedArg;
|
|
2232
|
-
}
|
|
2233
|
-
else if (models.includes(arg)) {
|
|
2234
|
-
newModel = arg;
|
|
2235
|
-
}
|
|
2236
|
-
else {
|
|
2237
|
-
const modelList = models.map((m) => ` ${m === currentModel ? '✓' : ' '} ${modelDisplayLabel(modelAgent, m)}`).join('\n');
|
|
2238
|
-
const effortHint = efforts.length > 0 ? `\n\n推理强度请使用 /effort 命令` : '';
|
|
2239
|
-
return { kind: 'command.error', text: `❌ 无效参数: ${arg}\n\n可用模型:\n${modelList}${effortHint}` };
|
|
2240
|
-
}
|
|
2241
|
-
}
|
|
2242
|
-
}
|
|
2243
|
-
else {
|
|
2244
|
-
// 双参数:model effort
|
|
2245
|
-
const [modelArgRaw, effortArg] = parts;
|
|
2246
|
-
const modelArg = hasModelSwitcher(modelAgent)
|
|
2247
|
-
? (models.includes(modelArgRaw) ? modelArgRaw : (modelAgent.resolveModelId?.(modelArgRaw) ?? modelArgRaw))
|
|
2248
|
-
: modelArgRaw;
|
|
2249
|
-
if (!models.includes(modelArg)) {
|
|
2250
|
-
return { kind: 'command.error', text: `❌ 无效的模型ID: ${modelArgRaw}` };
|
|
2251
|
-
}
|
|
2252
|
-
const targetEfforts = getAvailableEfforts(modelAgent, modelArg);
|
|
2253
|
-
if (targetEfforts.length === 0) {
|
|
2254
|
-
return { kind: 'command.error', text: `⚠️ ${modelArg} 不支持推理强度设置` };
|
|
2255
|
-
}
|
|
2256
|
-
if (!targetEfforts.includes(effortArg)) {
|
|
2257
|
-
const errorLabel = allEfforts.includes(effortArg) ? '⚠️' : '❌';
|
|
2258
|
-
return { kind: 'command.result', text: `${errorLabel} ${modelArg} 不支持 ${effortArg} 推理强度\n可选: ${targetEfforts.join(' / ')}` };
|
|
2259
|
-
}
|
|
2260
|
-
newModel = modelArg;
|
|
2261
|
-
newEffort = effortArg;
|
|
2262
|
-
}
|
|
2263
|
-
// 运行时 model/effort 切换已通过 EvolAgent.setBaseagentModel/setBaseagentEffort 持久化
|
|
2264
|
-
const isCodexAgent = modelAgent.name === 'codex';
|
|
2265
|
-
const changes = [];
|
|
2266
|
-
if (newModel) {
|
|
2267
|
-
modelAgent.setModel?.(newModel);
|
|
2268
|
-
this.eventBus.publish({
|
|
2269
|
-
type: 'runner:model-changed',
|
|
2270
|
-
sessionId: modelSession.id,
|
|
2271
|
-
model: newModel,
|
|
2272
|
-
timestamp: Date.now()
|
|
2273
|
-
});
|
|
2274
|
-
changes.push(`模型: ${newModel}`);
|
|
2275
|
-
}
|
|
2276
|
-
if (newEffort) {
|
|
2277
|
-
modelAgent.setEffort?.(newEffort);
|
|
2278
|
-
changes.push(`推理强度: ${newEffort}`);
|
|
2279
|
-
}
|
|
2280
|
-
// 持久化:agent-owned channel 写到 agent.json;default 走原"就近原则"
|
|
2281
|
-
if (newModel) {
|
|
2282
|
-
const err = this.persistBaseagentModel(channel, modelAgent.name, newModel);
|
|
2283
|
-
if (err)
|
|
2284
|
-
return { kind: 'command.result', text: `${err}\n已更新运行时配置,但未持久化` };
|
|
2285
|
-
}
|
|
2286
|
-
if (newEffort) {
|
|
2287
|
-
const err = this.persistBaseagentEffort(channel, modelAgent.name, newEffort);
|
|
2288
|
-
if (err)
|
|
2289
|
-
return { kind: 'command.result', text: `${err}\n已更新运行时配置,但未持久化` };
|
|
2290
|
-
}
|
|
2291
|
-
if (source === 'card-trigger')
|
|
2292
|
-
return null;
|
|
2293
|
-
return { kind: 'command.result', text: `✓ 已切换\n ${changes.join('\n ')}` };
|
|
2294
|
-
}
|
|
2295
|
-
// /effort 命令:查看或切换推理强度
|
|
2296
|
-
if (normalizedContent.startsWith('/effort')) {
|
|
2297
|
-
const args = normalizedContent.slice(7).trim();
|
|
2298
|
-
const effortResult = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
2299
|
-
if ('error' in effortResult)
|
|
2300
|
-
return { kind: 'command.result', text: effortResult.error };
|
|
2301
|
-
const { session: effortSession } = effortResult;
|
|
2302
|
-
const effortAgent = this.getAgent(channel, effortSession.agentId);
|
|
2303
|
-
const currentModel = hasModelSwitcher(effortAgent) ? effortAgent.getModel() : effortAgent.name;
|
|
2304
|
-
const efforts = getAvailableEfforts(effortAgent, currentModel);
|
|
2305
|
-
const currentEffort = effortAgent.getEffort?.() || 'auto';
|
|
2306
|
-
if (efforts.length === 0) {
|
|
2307
|
-
return { kind: 'command.error', text: '⚠️ 当前模型不支持推理强度设置' };
|
|
2308
|
-
}
|
|
2309
|
-
if (!args) {
|
|
2310
|
-
// /effort(无参数):显示当前推理强度 + 发送 CommandCard 卡片
|
|
2311
|
-
if (this.interactionRouter) {
|
|
2312
|
-
const allItems = [...efforts, 'auto'];
|
|
2313
|
-
const interaction = {
|
|
2314
|
-
type: 'interaction',
|
|
2315
|
-
id: `effort-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`,
|
|
2316
|
-
channelId,
|
|
2317
|
-
sessionId: effortSession.id,
|
|
2318
|
-
initiatorId: userId,
|
|
2319
|
-
kind: {
|
|
2320
|
-
kind: 'command-card',
|
|
2321
|
-
title: '⚡ 推理强度',
|
|
2322
|
-
buttons: allItems.map(e => ({
|
|
2323
|
-
label: e === currentEffort ? `✓ ${e}` : e,
|
|
2324
|
-
command: `/effort ${e}`,
|
|
2325
|
-
style: (e === currentEffort ? 'primary' : 'default'),
|
|
2326
|
-
disabled: e === currentEffort,
|
|
2327
|
-
})),
|
|
2328
|
-
},
|
|
2329
|
-
};
|
|
2330
|
-
const replyCtx = this.getReplyContext(effortSession);
|
|
2331
|
-
const cardResult = await this.sendCommandCard({ channel, channelId, interaction, replyCtx, canWrite: isAdmin });
|
|
2332
|
-
if (cardResult === null)
|
|
2333
|
-
return null;
|
|
2334
|
-
return { kind: 'command.result', text: cardResult };
|
|
2335
|
-
}
|
|
2336
|
-
// 降级:文本
|
|
2337
|
-
const effortDisplay = currentEffort === 'auto' ? 'auto (SDK默认)' : currentEffort;
|
|
2338
|
-
const effortOptions = [...efforts, 'auto'].join(' / ');
|
|
2339
|
-
if (isAdmin) {
|
|
2340
|
-
return { kind: 'command.result', text: `推理强度: ${effortDisplay} 可选: ${effortOptions} 用法: /effort <level>` };
|
|
2341
|
-
}
|
|
2342
|
-
return { kind: 'command.result', text: `推理强度: ${effortDisplay}` };
|
|
2343
|
-
}
|
|
2344
|
-
// 带参(切换)需 admin+;无参查询已在上方返回
|
|
2345
|
-
if (!isAdmin)
|
|
2346
|
-
return { kind: 'command.error', text: '❌ 无权限:切换推理强度仅限管理员使用' };
|
|
2347
|
-
// /effort auto:恢复 SDK 默认
|
|
2348
|
-
if (args === 'auto') {
|
|
2349
|
-
effortAgent.setEffort?.(undefined);
|
|
2350
|
-
const err = this.persistBaseagentEffort(channel, effortAgent.name, undefined);
|
|
2351
|
-
if (err)
|
|
2352
|
-
return { kind: 'command.result', text: `${err}\n已更新运行时配置,但未持久化` };
|
|
2353
|
-
return { kind: 'command.result', text: '✓ 推理强度已恢复为 auto (SDK默认)' };
|
|
2354
|
-
}
|
|
2355
|
-
// /effort <level>:切换推理强度
|
|
2356
|
-
if (!efforts.includes(args)) {
|
|
2357
|
-
if (allEfforts.includes(args)) {
|
|
2358
|
-
return { kind: 'command.error', text: `⚠️ ${currentModel} 不支持 ${args} 推理强度\n可选: ${efforts.join(' / ')}` };
|
|
2359
|
-
}
|
|
2360
|
-
return { kind: 'command.error', text: `❌ 无效参数: ${args}\n可选: ${efforts.join(' / ')} / auto` };
|
|
2361
|
-
}
|
|
2362
|
-
const newEffort = args;
|
|
2363
|
-
effortAgent.setEffort?.(newEffort);
|
|
2364
|
-
const err = this.persistBaseagentEffort(channel, effortAgent.name, newEffort);
|
|
2365
|
-
if (err)
|
|
2366
|
-
return { kind: 'command.result', text: `${err}\n已更新运行时配置,但未持久化` };
|
|
2367
|
-
if (source === 'card-trigger')
|
|
2368
|
-
return null;
|
|
2369
|
-
return { kind: 'command.result', text: `✓ 推理强度: ${newEffort}` };
|
|
2370
|
-
}
|
|
2371
|
-
// /agent, /aid, /rpc, /storage — 仅限 ctl 调用,slash 输入拒绝
|
|
2372
|
-
if (normalizedContent === '/agent' || normalizedContent.startsWith('/agent ') ||
|
|
2373
|
-
normalizedContent === '/aid' || normalizedContent.startsWith('/aid ') ||
|
|
2374
|
-
normalizedContent === '/rpc' || normalizedContent.startsWith('/rpc ') ||
|
|
2375
|
-
normalizedContent === '/storage' || normalizedContent.startsWith('/storage ')) {
|
|
2376
|
-
return { kind: 'command.error', text: '❌ 此命令仅限 ctl 调用,不支持 slash 输入' };
|
|
2377
|
-
}
|
|
2378
|
-
if (normalizedContent === '/activity' || normalizedContent.startsWith('/activity ')) {
|
|
2379
|
-
const activityArg = normalizedContent.slice(9).trim();
|
|
2380
|
-
// 带参(写操作)需 admin+;无参查询对所有人开放(owner 门在具体切换点还有一道)
|
|
2381
|
-
if (activityArg && !isAdmin)
|
|
2382
|
-
return { kind: 'command.error', text: '❌ 无权限:此命令仅限管理员使用' };
|
|
2383
|
-
// proactive 模式下流式输出全部静默,activity 配置无意义
|
|
2384
|
-
if (activeSession?.sessionMode === 'proactive') {
|
|
2385
|
-
return { kind: 'command.error', text: '❌ 当前会话为 proactive 模式,不支持 activity 配置(流式输出已全部静默)' };
|
|
2386
|
-
}
|
|
2387
|
-
const modeMap = {
|
|
2388
|
-
all: 'all',
|
|
2389
|
-
dm: 'dm-only',
|
|
2390
|
-
owner: 'owner-dm-only',
|
|
2391
|
-
none: 'none',
|
|
2392
|
-
};
|
|
2393
|
-
const currentMode = this.agentRegistry?.getShowActivities?.(channel) ?? 'all';
|
|
2394
|
-
// 模式描述列表(用于 body 和文本降级)
|
|
2395
|
-
const modeDescriptions = [
|
|
2396
|
-
{ key: 'all', configVal: 'all', label: '全部显示' },
|
|
2397
|
-
{ key: 'dm', configVal: 'dm-only', label: '仅私聊显示' },
|
|
2398
|
-
{ key: 'owner', configVal: 'owner-dm-only', label: '仅 owner 私聊显示' },
|
|
2399
|
-
{ key: 'none', configVal: 'none', label: '全部静默' },
|
|
2400
|
-
];
|
|
2401
|
-
if (!activityArg) {
|
|
2402
|
-
// 尝试发送 CommandCard 卡片
|
|
2403
|
-
{
|
|
2404
|
-
const interaction = {
|
|
2405
|
-
type: 'interaction',
|
|
2406
|
-
id: `activity-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`,
|
|
2407
|
-
channelId,
|
|
2408
|
-
sessionId: activeSession?.id || '',
|
|
2409
|
-
initiatorId: userId,
|
|
2410
|
-
kind: {
|
|
2411
|
-
kind: 'command-card',
|
|
2412
|
-
title: '📋 中间输出模式',
|
|
2413
|
-
body: modeDescriptions.map(m => `${m.configVal === currentMode ? '✓' : '•'} **${m.key}** (${m.label})`).join('\n'),
|
|
2414
|
-
buttons: modeDescriptions.map(m => ({
|
|
2415
|
-
label: m.configVal === currentMode ? `✓ ${m.key}` : m.key,
|
|
2416
|
-
command: `/activity ${m.key}`,
|
|
2417
|
-
style: (m.configVal === currentMode ? 'primary' : 'default'),
|
|
2418
|
-
disabled: m.configVal === currentMode,
|
|
2419
|
-
})),
|
|
2420
|
-
},
|
|
2421
|
-
};
|
|
2422
|
-
const replyCtx = activeSession ? this.getReplyContext(activeSession) : undefined;
|
|
2423
|
-
const cardResult = await this.sendCommandCard({ channel, channelId, interaction, replyCtx, canWrite: isOwner });
|
|
2424
|
-
if (cardResult === null)
|
|
2425
|
-
return null;
|
|
2426
|
-
// 卡片降级:fall through 到下方文本输出
|
|
2427
|
-
}
|
|
2428
|
-
// 降级:文本
|
|
2429
|
-
const modeList = modeDescriptions.map(m => {
|
|
2430
|
-
const prefix = m.configVal === currentMode ? '✓' : '•';
|
|
2431
|
-
return ` ${prefix} ${m.key} — ${m.label}`;
|
|
2432
|
-
}).join('\n');
|
|
2433
|
-
if (isOwner) {
|
|
2434
|
-
return { kind: 'command.result', text: `中间输出: ${currentMode} 用法: /activity <all|dm|owner|none>` };
|
|
2435
|
-
}
|
|
2436
|
-
return { kind: 'command.result', text: `中间输出: ${currentMode}` };
|
|
2437
|
-
}
|
|
2438
|
-
const newMode = modeMap[activityArg];
|
|
2439
|
-
if (!newMode) {
|
|
2440
|
-
return { kind: 'command.error', text: `❌ 无效参数: ${activityArg}\n可选: all / dm / owner / none` };
|
|
2441
|
-
}
|
|
2442
|
-
const label = modeDescriptions.find(m => m.configVal === newMode)?.label || newMode;
|
|
2443
|
-
if (newMode === currentMode) {
|
|
2444
|
-
return { kind: 'command.result', text: `📋 中间输出模式已是 ${activityArg}(${label})` };
|
|
2445
|
-
}
|
|
2446
|
-
// 切换操作仅 owner
|
|
2447
|
-
if (!isOwner)
|
|
2448
|
-
return { kind: 'command.error', text: '❌ 中间输出模式切换仅限 owner' };
|
|
2449
|
-
if (this.agentRegistry?.setShowActivities) {
|
|
2450
|
-
this.agentRegistry.setShowActivities(channel, newMode);
|
|
2451
|
-
}
|
|
2452
|
-
else {
|
|
2453
|
-
return { kind: 'command.error', text: `⚠️ 找不到通道 "${channel}" 所属的 self-agent,无法持久化` };
|
|
2454
|
-
}
|
|
2455
|
-
if (source === 'card-trigger')
|
|
2456
|
-
return null;
|
|
2457
|
-
return { kind: 'command.result', text: `✅ 中间输出模式: ${activityArg}(${label})` };
|
|
2458
|
-
}
|
|
2459
|
-
// /chatmode 命令:查看/切换 session 会话模式(interactive | proactive)
|
|
2460
|
-
// - 查看:所有人可用
|
|
2461
|
-
// - 设置:单聊任何角色可设置;群聊仅管理员可设置
|
|
2462
|
-
if (normalizedContent === '/chatmode' || normalizedContent.startsWith('/chatmode ')) {
|
|
2463
|
-
const chatmodeResult = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
2464
|
-
if ('error' in chatmodeResult)
|
|
2465
|
-
return { kind: 'command.result', text: chatmodeResult.error };
|
|
2466
|
-
const chatmodeSession = chatmodeResult.session;
|
|
2467
|
-
const arg = normalizedContent.slice(9).trim();
|
|
2468
|
-
const currentMode = chatmodeSession.sessionMode || 'interactive';
|
|
2469
|
-
const chatmodeChatType = chatmodeSession.chatType || activeChatType;
|
|
2470
|
-
const isGroup = chatmodeChatType === 'group';
|
|
2471
|
-
const canSwitch = !isGroup;
|
|
2472
|
-
if (!arg) {
|
|
2473
|
-
if (isGroup) {
|
|
2474
|
-
return { kind: 'command.result', text: `📋 会话模式: proactive(群聊强制)` };
|
|
2475
|
-
}
|
|
2476
|
-
// 尝试发送 CommandCard 卡片
|
|
2477
|
-
if (canSwitch) {
|
|
2478
|
-
const modes = [
|
|
2479
|
-
{ key: 'interactive', name: '交互模式', desc: '被动响应:收到消息时才回复,回复直接显示' },
|
|
2480
|
-
{ key: 'proactive', name: '主动模式', desc: '主动推进:流式输出静默,由 Agent 自调 ctl send 发声' },
|
|
2481
|
-
];
|
|
2482
|
-
const interaction = {
|
|
2483
|
-
type: 'interaction',
|
|
2484
|
-
id: `chatmode-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`,
|
|
2485
|
-
channelId,
|
|
2486
|
-
sessionId: chatmodeSession.id,
|
|
2487
|
-
initiatorId: userId,
|
|
2488
|
-
kind: {
|
|
2489
|
-
kind: 'command-card',
|
|
2490
|
-
title: '🔄 会话模式',
|
|
2491
|
-
body: modes.map(m => `${m.key === currentMode ? '✓' : '•'} **${m.key}** (${m.name}) - ${m.desc}`).join('\n'),
|
|
2492
|
-
buttons: modes.map(m => ({
|
|
2493
|
-
label: m.key === currentMode ? `✓ ${m.key}` : m.key,
|
|
2494
|
-
command: `/chatmode ${m.key}`,
|
|
2495
|
-
style: (m.key === currentMode ? 'primary' : 'default'),
|
|
2496
|
-
disabled: m.key === currentMode,
|
|
2497
|
-
})),
|
|
2498
|
-
},
|
|
2499
|
-
};
|
|
2500
|
-
const replyCtx = this.getReplyContext(chatmodeSession);
|
|
2501
|
-
const cardResult = await this.sendCommandCard({ channel, channelId, interaction, replyCtx, canWrite: isAdmin });
|
|
2502
|
-
if (cardResult === null)
|
|
2503
|
-
return null;
|
|
2504
|
-
// 卡片降级:fall through 到下方文本输出
|
|
2505
|
-
}
|
|
2506
|
-
// 降级:文本
|
|
2507
|
-
if (canSwitch) {
|
|
2508
|
-
return { kind: 'command.result', text: `会话模式: ${currentMode} 用法: /chatmode <interactive|proactive>` };
|
|
2509
|
-
}
|
|
2510
|
-
return { kind: 'command.result', text: `会话模式: ${currentMode}` };
|
|
2511
|
-
}
|
|
2512
|
-
if (arg !== 'interactive' && arg !== 'proactive') {
|
|
2513
|
-
return { kind: 'command.error', text: `❌ 无效模式: ${arg}\n可选: interactive / proactive` };
|
|
2514
|
-
}
|
|
2515
|
-
// 群聊强制 proactive,不可切换
|
|
2516
|
-
if ((chatmodeSession.chatType || activeChatType) === 'group') {
|
|
2517
|
-
return { kind: 'command.error', text: '❌ 群聊强制 proactive 模式,不可切换' };
|
|
2518
|
-
}
|
|
2519
|
-
if (arg === currentMode) {
|
|
2520
|
-
return { kind: 'command.result', text: `📋 当前会话模式已是 ${arg}` };
|
|
2521
|
-
}
|
|
2522
|
-
// 仅在真正需要切换时才要求会话空闲
|
|
2523
|
-
if (threadId) {
|
|
2524
|
-
const threadSession = await this.sessionManager.getThreadSession(channel, channelId, threadId);
|
|
2525
|
-
if (threadSession) {
|
|
2526
|
-
const threadAgent = this.getAgent(channel, threadSession.agentId);
|
|
2527
|
-
if (threadAgent.hasActiveStream(threadSession.id) || this.messageQueue?.isProcessing(threadSession.id)) {
|
|
2528
|
-
return { kind: 'command.error', text: '⚠️ 当前正在处理消息,请稍后再试\n使用 /stop 中断当前任务后重试' };
|
|
2529
|
-
}
|
|
2530
|
-
}
|
|
2531
|
-
}
|
|
2532
|
-
else if (agent.hasActiveStream(chatmodeSession.id) || this.messageQueue?.isProcessing(chatmodeSession.id)) {
|
|
2533
|
-
return { kind: 'command.error', text: '⚠️ 当前正在处理消息,请稍后再试\n使用 /stop 中断当前任务后重试' };
|
|
2534
|
-
}
|
|
2535
|
-
await this.sessionManager.updateSession(chatmodeSession.id, { sessionMode: arg });
|
|
2536
|
-
this.eventBus.publish({ type: 'session:chat-mode-changed', sessionId: chatmodeSession.id, mode: arg, timestamp: Date.now() });
|
|
2537
|
-
if (source === 'card-trigger')
|
|
2538
|
-
return null;
|
|
2539
|
-
return { kind: 'command.result', text: `✅ 会话模式已切换: ${arg}` };
|
|
2540
|
-
}
|
|
2541
|
-
// /dispatch 命令:查看/切换群聊分发模式(mention | broadcast)
|
|
2542
|
-
// 仅群聊可用;群聊中设置需管理员权限
|
|
2543
|
-
if (normalizedContent === '/dispatch' || normalizedContent.startsWith('/dispatch ')) {
|
|
2544
|
-
const dispatchResult = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
2545
|
-
if ('error' in dispatchResult)
|
|
2546
|
-
return { kind: 'command.result', text: dispatchResult.error };
|
|
2547
|
-
const dispatchSession = dispatchResult.session;
|
|
2548
|
-
const dispatchChatType = dispatchSession.chatType || activeChatType;
|
|
2549
|
-
if (dispatchChatType !== 'group') {
|
|
2550
|
-
return { kind: 'command.error', text: '❌ /dispatch 仅在群聊中可用' };
|
|
2551
|
-
}
|
|
2552
|
-
const arg = normalizedContent.slice(9).trim();
|
|
2553
|
-
const currentMode = dispatchSession.metadata?.dispatchMode;
|
|
2554
|
-
if (!arg) {
|
|
2555
|
-
const displayMode = currentMode ?? '未设置(跟随群设置)';
|
|
2556
|
-
// 尝试发送 CommandCard 卡片
|
|
2557
|
-
if (isAdmin) {
|
|
2558
|
-
const modes = [
|
|
2559
|
-
{ key: 'mention', name: '提及模式', desc: '仅当被 @ 提及(含 @all)时响应群消息' },
|
|
2560
|
-
{ key: 'broadcast', name: '广播模式', desc: '群内所有消息都触发响应' },
|
|
2561
|
-
];
|
|
2562
|
-
const interaction = {
|
|
2563
|
-
type: 'interaction',
|
|
2564
|
-
id: `dispatch-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`,
|
|
2565
|
-
channelId,
|
|
2566
|
-
sessionId: dispatchSession.id,
|
|
2567
|
-
initiatorId: userId,
|
|
2568
|
-
kind: {
|
|
2569
|
-
kind: 'command-card',
|
|
2570
|
-
title: '📡 分发模式',
|
|
2571
|
-
body: modes.map(m => `${m.key === currentMode ? '✓' : '•'} **${m.key}** (${m.name}) - ${m.desc}`).join('\n'),
|
|
2572
|
-
buttons: modes.map(m => ({
|
|
2573
|
-
label: m.key === currentMode ? `✓ ${m.key}` : m.key,
|
|
2574
|
-
command: `/dispatch ${m.key}`,
|
|
2575
|
-
style: (m.key === currentMode ? 'primary' : 'default'),
|
|
2576
|
-
disabled: m.key === currentMode,
|
|
2577
|
-
})),
|
|
2578
|
-
},
|
|
2579
|
-
};
|
|
2580
|
-
const replyCtx = this.getReplyContext(dispatchSession);
|
|
2581
|
-
const cardResult = await this.sendCommandCard({ channel, channelId, interaction, replyCtx, canWrite: isAdmin });
|
|
2582
|
-
if (cardResult === null)
|
|
2583
|
-
return null;
|
|
2584
|
-
// 卡片降级:fall through 到下方文本输出
|
|
2585
|
-
}
|
|
2586
|
-
// 降级:文本
|
|
2587
|
-
if (isAdmin) {
|
|
2588
|
-
return { kind: 'command.result', text: `分发模式: ${displayMode} 用法: /dispatch <mention|broadcast>` };
|
|
2589
|
-
}
|
|
2590
|
-
return { kind: 'command.result', text: `分发模式: ${displayMode}` };
|
|
2591
|
-
}
|
|
2592
|
-
if (arg !== 'mention' && arg !== 'broadcast') {
|
|
2593
|
-
return { kind: 'command.error', text: `❌ 无效模式: ${arg}\n可选: mention / broadcast\n用法: /dispatch <模式>` };
|
|
2594
|
-
}
|
|
2595
|
-
if (!isAdmin) {
|
|
2596
|
-
return { kind: 'command.error', text: '❌ 无权限:群聊中切换分发模式仅限管理员使用' };
|
|
2597
|
-
}
|
|
2598
|
-
if (arg === currentMode) {
|
|
2599
|
-
return { kind: 'command.result', text: `当前已是 ${arg}` };
|
|
2600
|
-
}
|
|
2601
|
-
const metadata = { ...(dispatchSession.metadata || {}), dispatchMode: arg };
|
|
2602
|
-
await this.sessionManager.updateSession(dispatchSession.id, { metadata });
|
|
2603
|
-
this.eventBus.publish({ type: 'session:dispatch-mode-changed', sessionId: dispatchSession.id, mode: arg, timestamp: Date.now() });
|
|
2604
|
-
if (source === 'card-trigger')
|
|
2605
|
-
return null;
|
|
2606
|
-
return { kind: 'command.result', text: `✅ 分发模式已切换: ${currentMode ?? '未设置'} → ${arg}` };
|
|
2607
|
-
}
|
|
2608
|
-
// /stop 命令:中断当前任务
|
|
2609
|
-
if (normalizedContent === '/stop') {
|
|
2610
|
-
const stopResult = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
2611
|
-
if ('error' in stopResult)
|
|
2612
|
-
return { kind: 'command.result', text: '当前没有正在处理的任务' };
|
|
2613
|
-
const { session: stopSession } = stopResult;
|
|
2614
|
-
const stopAgent = this.getAgent(channel, stopSession.agentId);
|
|
2615
|
-
const sessionKey = stopSession.id;
|
|
2616
|
-
const queueLength = this.messageQueue.getQueueLength(sessionKey);
|
|
2617
|
-
const hasActive = stopAgent.hasActiveStream(sessionKey);
|
|
2618
|
-
const isProcessing = this.messageQueue.isProcessing(sessionKey);
|
|
2619
|
-
if (queueLength === 0 && !hasActive && !isProcessing) {
|
|
2620
|
-
return { kind: 'command.result', text: '当前没有正在处理的任务' };
|
|
2621
|
-
}
|
|
2622
|
-
await stopAgent.interrupt(sessionKey);
|
|
2623
|
-
// 发布中断事件,让 MessageProcessor 标记为 interrupted(而非 done)
|
|
2624
|
-
this.eventBus.publish({
|
|
2625
|
-
type: 'task:interrupted',
|
|
2626
|
-
sessionId: sessionKey,
|
|
2627
|
-
reason: 'stop',
|
|
2628
|
-
agentName: this.agentRegistry?.resolveByChannel(channel)?.name ?? '<unknown>',
|
|
2629
|
-
});
|
|
2630
|
-
// 强制清除 processing_state
|
|
2631
|
-
this.sessionManager.clearProcessing(sessionKey);
|
|
2632
|
-
return { kind: 'command.result', text: '✓ 已发送中断信号,任务将尽快停止' };
|
|
2633
|
-
}
|
|
2634
|
-
// /clear 已移除:Claude/Codex/Gemini 对“清空当前 backend 历史”的语义不一致。
|
|
2635
|
-
// 统一使用 /new 创建新会话来开始全新上下文。
|
|
2636
|
-
if (normalizedContent === '/clear') {
|
|
2637
|
-
return { kind: 'command.error', text: '⚠️ /clear 已移除\n\n请使用 /new [名称] 创建新会话来开始全新上下文。旧会话会保留,可通过 /s 查看或切换。' };
|
|
2638
|
-
}
|
|
2639
|
-
// /compact 命令:手动压缩会话上下文
|
|
2640
|
-
if (normalizedContent === '/compact') {
|
|
2641
|
-
const result = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
2642
|
-
if ('error' in result)
|
|
2643
|
-
return { kind: 'command.error', text: result.error };
|
|
2644
|
-
const { session } = result;
|
|
2645
|
-
const sessionAgent = this.getAgent(channel, session.agentId);
|
|
2646
|
-
if (!sessionAgent.capabilities?.compact) {
|
|
2647
|
-
return { kind: 'command.error', text: `❌ 当前 Agent (${sessionAgent.name}) 不支持 /compact` };
|
|
2648
|
-
}
|
|
2649
|
-
if (!session.agentSessionId) {
|
|
2650
|
-
return { kind: 'command.error', text: '❌ 当前会话没有历史记录,无需压缩' };
|
|
2651
|
-
}
|
|
2652
|
-
const projectPath = path.isAbsolute(session.projectPath)
|
|
2653
|
-
? session.projectPath
|
|
2654
|
-
: path.resolve(process.cwd(), session.projectPath);
|
|
2655
|
-
const releaseLock = this.messageQueue.acquireLock(session.id);
|
|
2656
|
-
try {
|
|
2657
|
-
if (sendMessage) {
|
|
2658
|
-
await sendMessage(channelId, '⏳ 正在压缩会话上下文...', this.getReplyContext(session));
|
|
2659
|
-
}
|
|
2660
|
-
const compacted = await sessionAgent.compactSession(session.id, session.agentSessionId, projectPath);
|
|
2661
|
-
if (compacted) {
|
|
2662
|
-
return {
|
|
2663
|
-
kind: 'command.result',
|
|
2664
|
-
text: '✅ 会话压缩完成',
|
|
2665
|
-
};
|
|
2666
|
-
}
|
|
2667
|
-
else {
|
|
2668
|
-
return { kind: 'command.error', text: '❌ 会话压缩失败,请稍后重试' };
|
|
2669
|
-
}
|
|
2670
|
-
}
|
|
2671
|
-
finally {
|
|
2672
|
-
releaseLock();
|
|
2673
|
-
}
|
|
2674
|
-
}
|
|
2675
|
-
// 尝试获取活跃会话(话题时直接查找话题 session)
|
|
2676
|
-
let session;
|
|
2677
|
-
const resolvedSelfAID = selfAID ?? this.resolveSelfAID(channel);
|
|
2678
|
-
if (threadId) {
|
|
2679
|
-
session = await this.sessionManager.getOrCreateSession(channel, channelId, this.getEffectiveDefaultPath(channel), threadId, undefined, undefined, undefined, chatType, undefined, resolvedSelfAID, this.resolveChannelType(channel));
|
|
2680
|
-
}
|
|
2681
|
-
else {
|
|
2682
|
-
session = await this.sessionManager.getActiveSession(channel, channelId);
|
|
2683
|
-
}
|
|
2684
|
-
// 如果没有会话,自动创建(所有后续命令都需要 session)
|
|
2685
|
-
if (!session) {
|
|
2686
|
-
session = await this.sessionManager.getOrCreateSession(channel, channelId, this.getEffectiveDefaultPath(channel), undefined, undefined, undefined, undefined, chatType, undefined, resolvedSelfAID, this.resolveChannelType(channel));
|
|
2687
|
-
}
|
|
2688
|
-
// /status 命令:显示会话状态
|
|
2689
|
-
if (normalizedContent === '/status') {
|
|
2690
|
-
// session 现在总是存在(上面已自动创建)
|
|
2691
|
-
if (!session) {
|
|
2692
|
-
return { kind: 'command.error', text: `❌ 无法创建会话,请检查配置` };
|
|
2693
|
-
}
|
|
2694
|
-
const sessionKey = this.getQueueKey(session, channel, channelId);
|
|
2695
|
-
const sessionAgent = this.getAgent(channel, session.agentId);
|
|
2696
|
-
const isCurrentlyProcessing = this.messageQueue.isProcessing(sessionKey) || sessionAgent.hasActiveStream(sessionKey);
|
|
2697
|
-
const queueLength = this.messageQueue.getQueueLength(sessionKey);
|
|
2698
|
-
const isThread = !!session.threadId;
|
|
2699
|
-
let sessionStatus = isCurrentlyProcessing ? '处理中' : '空闲';
|
|
2700
|
-
// 处理中时显示时长
|
|
2701
|
-
if (isCurrentlyProcessing) {
|
|
2702
|
-
const elapsed = Date.now() - parseInt(session.processingState, 10);
|
|
2703
|
-
if (!isNaN(elapsed) && elapsed > 0) {
|
|
2704
|
-
const sec = Math.floor(elapsed / 1000);
|
|
2705
|
-
sessionStatus = sec < 60 ? `处理中 (${sec}秒)` :
|
|
2706
|
-
sec < 3600 ? `处理中 (${Math.floor(sec / 60)}分钟)` :
|
|
2707
|
-
`处理中 (${Math.floor(sec / 3600)}小时)`;
|
|
2708
|
-
}
|
|
2709
|
-
}
|
|
2710
|
-
const projectName = this.getProjectName(session.projectPath);
|
|
2711
|
-
const owningAgent = this.getOwningAgent(channel);
|
|
2712
|
-
const agentName = owningAgent?.name ?? 'DefaultAgent';
|
|
2713
|
-
const health = await this.sessionManager.getHealthStatus(session.id);
|
|
2714
|
-
const timeSinceSuccess = Date.now() - health.lastSuccessTime;
|
|
2715
|
-
const timeStr = timeSinceSuccess < 60000 ? '刚刚' :
|
|
2716
|
-
timeSinceSuccess < 3600000 ? `${Math.floor(timeSinceSuccess / 60000)}分钟前` :
|
|
2717
|
-
`${Math.floor(timeSinceSuccess / 3600000)}小时前`;
|
|
2718
|
-
// 获取会话文件信息并同步 name
|
|
2719
|
-
let sessionTurns = 0;
|
|
2720
|
-
if (session.agentSessionId) {
|
|
2721
|
-
const fileInfo = this.sessionManager.getSessionFileInfo(session.projectPath, session.agentSessionId, session.agentId);
|
|
2722
|
-
sessionTurns = fileInfo.turns;
|
|
2723
|
-
if (fileInfo.title && fileInfo.title !== session.name) {
|
|
2724
|
-
await this.sessionManager.renameSession(session.id, fileInfo.title);
|
|
2725
|
-
session.name = fileInfo.title;
|
|
2726
|
-
}
|
|
2727
|
-
}
|
|
2728
|
-
const lines = [];
|
|
2729
|
-
const sessionMode = session.sessionMode || 'interactive';
|
|
2730
|
-
const dispatchMode = session.metadata?.dispatchMode ?? '未设置(跟随群设置)';
|
|
2731
|
-
const chatModeLine = `会话模式: ${sessionMode}`;
|
|
2732
|
-
const dispatchModeLine = session.chatType === 'group' ? `分发模式: ${dispatchMode}` : null;
|
|
2733
|
-
if (isAdmin) {
|
|
2734
|
-
lines.push(`📊 ${isThread ? '话题' : '会话'}状态 (Agent: ${agentName}):`, `渠道: ${this.resolveChannelType(channel)} / 项目: ${projectName} / 会话: ${displaySessionTitle(session.name, '(未命名)')}`, `会话ID: ${session.id}`, `项目路径: ${session.projectPath}`, `会话状态: ${sessionStatus}`, chatModeLine, ...(dispatchModeLine ? [dispatchModeLine] : []), `会话轮数: ${sessionTurns}`);
|
|
2735
|
-
if (health.consecutiveErrors > 0) {
|
|
2736
|
-
lines.push(`异常计数: ${health.consecutiveErrors}`);
|
|
2737
|
-
}
|
|
2738
|
-
lines.push(`最后成功: ${timeStr}`, `${session.agentId}会话: ${session.agentSessionId || '(未初始化)'}`, `创建时间: ${new Date(session.createdAt).toLocaleString('zh-CN')}`, `更新时间: ${new Date(session.updatedAt).toLocaleString('zh-CN')}`);
|
|
2739
|
-
}
|
|
2740
|
-
else {
|
|
2741
|
-
lines.push(`📊 ${isThread ? '话题' : '会话'}状态 (Agent: ${agentName}):`, `渠道: ${channel} / 项目: ${projectName} / ${session.agentId}会话`, `状态: ${sessionStatus}`, chatModeLine, ...(dispatchModeLine ? [dispatchModeLine] : []), `会话轮数: ${sessionTurns}`, `最后活跃: ${timeStr}`);
|
|
2742
|
-
}
|
|
2743
|
-
if (health.lastError) {
|
|
2744
|
-
lines.push('');
|
|
2745
|
-
lines.push(`最后错误: ${health.lastErrorType || 'unknown'}`);
|
|
2746
|
-
lines.push(`错误信息: ${health.lastError.substring(0, 100)}`);
|
|
2747
|
-
}
|
|
2748
|
-
return { kind: 'command.result', text: lines.join('\n') };
|
|
2749
|
-
}
|
|
2750
|
-
// /new 命令:创建新会话(支持命名)
|
|
2751
|
-
if (normalizedContent.startsWith('/new')) {
|
|
2752
|
-
const sessionName = normalizedContent.slice(4).trim() || undefined;
|
|
2753
|
-
if (sessionName) {
|
|
2754
|
-
const existing = await this.sessionManager.getSessionByName(channel, channelId, sessionName);
|
|
2755
|
-
if (existing) {
|
|
2756
|
-
return { kind: 'command.error', text: `❌ 会话名称 "${sessionName}" 已存在,请使用其他名称` };
|
|
2757
|
-
}
|
|
2758
|
-
}
|
|
2759
|
-
const projectPath = this.getEffectiveDefaultPath(channel);
|
|
2760
|
-
if (sendMessage && session) {
|
|
2761
|
-
await sendMessage(channelId, `⏳ 正在创建新会话${sessionName ? `: ${sessionName}` : ''}...`, this.getReplyContext(session));
|
|
2762
|
-
}
|
|
2763
|
-
const newSession = await this.sessionManager.createNewSession(channel, channelId, projectPath, sessionName, session?.agentId || this.primaryRunnerKey);
|
|
2764
|
-
this.eventBus.publish({
|
|
2765
|
-
type: 'session:created',
|
|
2766
|
-
sessionId: newSession.id,
|
|
2767
|
-
channel,
|
|
2768
|
-
channelId,
|
|
2769
|
-
projectPath,
|
|
2770
|
-
name: sessionName,
|
|
2771
|
-
timestamp: Date.now()
|
|
2772
|
-
});
|
|
2773
|
-
if (session) {
|
|
2774
|
-
// Reset agent backend state so the new
|
|
2775
|
-
// session starts with a fresh conversation history
|
|
2776
|
-
await agent.clearSession(session.id, session.agentSessionId || '', session.projectPath);
|
|
2777
|
-
await agent.closeSession(session.id);
|
|
2778
|
-
}
|
|
2779
|
-
return { kind: 'command.result', text: `✓ 已创建新会话${sessionName ? `: ${sessionName}` : ''}\n 项目: ${this.getProjectName(projectPath)}\n 之前的对话历史已保留,可通过 /s 查看` };
|
|
2780
|
-
}
|
|
2781
|
-
// /check 命令:检查渠道状态(guest 可用,详情仅 admin)/ 重连指定渠道(admin only)
|
|
2782
|
-
if (normalizedContent === '/check' || normalizedContent.startsWith('/check ')) {
|
|
2783
|
-
const subCmd = normalizedContent.slice('/check'.length).trim();
|
|
2784
|
-
// 限定可见渠道:agent-owned 通道仅显示该 agent 名下的渠道;
|
|
2785
|
-
// __ecweb__ 是 ECWeb 系统级入口,展示全量渠道
|
|
2786
|
-
const checkOwningAgent = this.getOwningAgent(channel);
|
|
2787
|
-
let allowedChannels;
|
|
2788
|
-
if (checkOwningAgent) {
|
|
2789
|
-
allowedChannels = new Set(checkOwningAgent.channelInstanceNames());
|
|
2790
|
-
}
|
|
2791
|
-
else if (channel === '__ecweb__') {
|
|
2792
|
-
// ECWeb 全局视图:展示所有渠道
|
|
2793
|
-
allowedChannels = new Set(this.adapters.keys());
|
|
2794
|
-
}
|
|
2795
|
-
else {
|
|
2796
|
-
// default 范围:不再有 default channel 概念,等价于"所有 channel"
|
|
2797
|
-
const defaultNames = [];
|
|
2798
|
-
for (const [name] of this.adapters) {
|
|
2799
|
-
const owner = this.agentRegistry?.resolveByChannel(name);
|
|
2800
|
-
if (!owner)
|
|
2801
|
-
defaultNames.push(name);
|
|
2802
|
-
}
|
|
2803
|
-
allowedChannels = new Set(defaultNames);
|
|
2804
|
-
}
|
|
2805
|
-
// Default: show system health check (non-admin 仅看摘要)
|
|
2806
|
-
const checkAgentName = checkOwningAgent?.name ?? 'DefaultAgent';
|
|
2807
|
-
const lines = [`📡 渠道状态 (Agent: ${checkAgentName}):`];
|
|
2808
|
-
// Group by channelType
|
|
2809
|
-
const groups = new Map();
|
|
2810
|
-
for (const [name] of this.adapters) {
|
|
2811
|
-
if (!allowedChannels.has(name))
|
|
2812
|
-
continue;
|
|
2813
|
-
const type = this.channelTypeMap.get(name) || name;
|
|
2814
|
-
const ch = this.channelObjects.get(name);
|
|
2815
|
-
let status;
|
|
2816
|
-
if (ch?.getStatus) {
|
|
2817
|
-
const s = ch.getStatus();
|
|
2818
|
-
status = s.connected ? '✓ 已连接' : '⏳ 重连中';
|
|
2819
|
-
}
|
|
2820
|
-
else {
|
|
2821
|
-
status = '✓ 已注册';
|
|
2822
|
-
}
|
|
2823
|
-
if (!groups.has(type))
|
|
2824
|
-
groups.set(type, []);
|
|
2825
|
-
groups.get(type).push({ name, status });
|
|
2826
|
-
}
|
|
2827
|
-
if (!isAdmin) {
|
|
2828
|
-
// guest/user: 仅显示渠道健康摘要
|
|
2829
|
-
const total = [...groups.values()].flat().length;
|
|
2830
|
-
const healthy = [...groups.values()].flat().filter(i => i.status.includes('✓')).length;
|
|
2831
|
-
lines.push(` ${healthy}/${total} 渠道正常`);
|
|
2832
|
-
return { kind: 'command.result', text: lines.join('\n') };
|
|
2833
|
-
}
|
|
2834
|
-
for (const [type, instances] of groups) {
|
|
2835
|
-
if (instances.length === 1) {
|
|
2836
|
-
lines.push(` ${type}: ${instances[0].status}`);
|
|
2837
|
-
}
|
|
2838
|
-
else {
|
|
2839
|
-
const parts = instances.map(i => {
|
|
2840
|
-
const seg = i.name.split('#');
|
|
2841
|
-
const instName = seg.length >= 3 ? seg.slice(2).join('#') : i.name;
|
|
2842
|
-
return `${i.status.includes('✓') ? '✓' : '⏳'} ${instName}`;
|
|
2843
|
-
});
|
|
2844
|
-
lines.push(` ${type}: ${parts.join(', ')}`);
|
|
2845
|
-
}
|
|
2846
|
-
}
|
|
2847
|
-
// 当前 agent 名(用于 agent 维度 stats / queue 查询)
|
|
2848
|
-
const currentAgentName = checkOwningAgent?.name ?? '<unknown>';
|
|
2849
|
-
// 队列状态(按当前 agent 维度)
|
|
2850
|
-
lines.push('', '📬 队列状态:');
|
|
2851
|
-
lines.push(` 待处理消息: ${this.messageQueue.getQueueLengthByAgent(currentAgentName)}`);
|
|
2852
|
-
lines.push(` 处理中队列: ${this.messageQueue.getProcessingCountByAgent(currentAgentName)}`);
|
|
2853
|
-
// 运行概况(全局,进程级)
|
|
2854
|
-
lines.push('', '🖥️ 运行概况:');
|
|
2855
|
-
const uptimeMs = this.statsCollector
|
|
2856
|
-
? this.statsCollector.getSnapshot().uptimeMs
|
|
2857
|
-
: process.uptime() * 1000;
|
|
2858
|
-
lines.push(` 运行时间: ${this.formatUptime(uptimeMs)}`);
|
|
2859
|
-
// 近 1 小时统计(按当前 agent 维度)
|
|
2860
|
-
if (this.statsCollector) {
|
|
2861
|
-
const snap = this.statsCollector.getSnapshot(currentAgentName);
|
|
2862
|
-
const h = snap.lastHour;
|
|
2863
|
-
lines.push('', '📊 近 1 小时统计:');
|
|
2864
|
-
lines.push(` 收到消息: ${h.received}`);
|
|
2865
|
-
lines.push(` 完成处理: ${h.completed}`);
|
|
2866
|
-
if (h.errors > 0) {
|
|
2867
|
-
const breakdown = Object.entries(h.errorsByType).map(([t, c]) => `${t}: ${c}`).join(', ');
|
|
2868
|
-
lines.push(` 处理出错: ${h.errors} (${breakdown})`);
|
|
2869
|
-
}
|
|
2870
|
-
else {
|
|
2871
|
-
lines.push(` 处理出错: 0`);
|
|
2872
|
-
}
|
|
2873
|
-
if (h.toolErrors > 0) {
|
|
2874
|
-
const toolBreakdown = Object.entries(h.toolErrorsByName).map(([t, c]) => `${t}: ${c}`).join(', ');
|
|
2875
|
-
lines.push(` 工具失败: ${h.toolErrors} (${toolBreakdown})`);
|
|
2876
|
-
}
|
|
2877
|
-
lines.push(` 被中断: ${h.interrupts}`);
|
|
2878
|
-
if (h.completed > 0) {
|
|
2879
|
-
lines.push(` 平均响应耗时: ${(h.avgResponseMs / 1000).toFixed(1)}s`);
|
|
2880
|
-
}
|
|
2881
|
-
}
|
|
2882
|
-
const checkSnap = this.statsCollector?.getSnapshot(currentAgentName);
|
|
2883
|
-
const structured = {
|
|
2884
|
-
channels: [...groups.entries()].map(([type, instances]) => ({ type, instances })),
|
|
2885
|
-
queue: {
|
|
2886
|
-
pending: this.messageQueue.getQueueLengthByAgent(currentAgentName),
|
|
2887
|
-
processing: this.messageQueue.getProcessingCountByAgent(currentAgentName),
|
|
2888
|
-
},
|
|
2889
|
-
uptimeMs,
|
|
2890
|
-
lastHour: checkSnap?.lastHour ?? null,
|
|
2891
|
-
evolagents: this.agentRegistry?.list().map((ag) => ({
|
|
2892
|
-
name: ag.name, aid: ag.aid ?? '', status: ag.status,
|
|
2893
|
-
baseagent: ag.baseagent ?? null,
|
|
2894
|
-
activeTasks: this.messageQueue.getProcessingCountByAgent(ag.name)
|
|
2895
|
-
+ this.messageQueue.getQueueLengthByAgent(ag.name),
|
|
2896
|
-
error: ag.error,
|
|
2897
|
-
})) ?? [],
|
|
2898
|
-
baseagents: [...this.agentMap.entries()].map(([key, runner]) => ({
|
|
2899
|
-
name: key.split('::')[1] ?? key,
|
|
2900
|
-
activeStreams: runner.activeStreamCount?.() ?? 0,
|
|
2901
|
-
healthy: true,
|
|
2902
|
-
})),
|
|
2903
|
-
};
|
|
2904
|
-
return { kind: 'command.result', text: lines.join('\n'), structured };
|
|
2905
|
-
}
|
|
2906
|
-
// /restart 命令:重启服务(owner only)
|
|
2907
|
-
if (normalizedContent === '/restart') {
|
|
2908
|
-
// /restart(无参数)— 重启整个服务(owner only)
|
|
2909
|
-
if (!isOwner)
|
|
2910
|
-
return { kind: 'command.error', text: '❌ 无权限:服务重启仅限 owner 使用' };
|
|
2911
|
-
const allSessions = await this.sessionManager.listSessions(channel, channelId);
|
|
2912
|
-
const sessionsWithMessages = allSessions
|
|
2913
|
-
.filter(s => this.messageCache.hasMessages(s.id))
|
|
2914
|
-
.map(s => {
|
|
2915
|
-
const count = this.messageCache.getCount(s.id);
|
|
2916
|
-
return `${s.projectPath} 有 ${count} 条新消息`;
|
|
2917
|
-
});
|
|
2918
|
-
// 执行重启逻辑(共用于卡片回调和文本确认)
|
|
2919
|
-
const executeRestart = async () => {
|
|
2920
|
-
let replyContext;
|
|
2921
|
-
if (threadId) {
|
|
2922
|
-
const threadSession = await this.sessionManager.getOrCreateSession(channel, channelId, this.getEffectiveDefaultPath(channel), threadId, undefined, undefined, undefined, undefined, undefined, selfAID ?? this.resolveSelfAID(channel), this.resolveChannelType(channel));
|
|
2923
|
-
replyContext = this.getReplyContext(threadSession);
|
|
2924
|
-
}
|
|
2925
|
-
const restartInfo = {
|
|
2926
|
-
channel,
|
|
2927
|
-
channelId,
|
|
2928
|
-
timestamp: Date.now(),
|
|
2929
|
-
...(replyContext?.replyToMessageId ? { rootId: replyContext.replyToMessageId } : {}),
|
|
2930
|
-
};
|
|
2931
|
-
fs.writeFileSync(path.join(resolvePaths().dataDir, 'restart-pending.json'), JSON.stringify(restartInfo));
|
|
2932
|
-
const { spawn } = await import('child_process');
|
|
2933
|
-
spawn('node', [path.join(getPackageRoot(), 'dist', 'cli', 'index.js'), 'restart-monitor'], {
|
|
2934
|
-
detached: true,
|
|
2935
|
-
stdio: 'ignore',
|
|
2936
|
-
env: { ...process.env, EVOLCLAW_HOME: resolvePaths().root }
|
|
2937
|
-
}).unref();
|
|
2938
|
-
this.eventBus.publish({ type: 'system:restart', channel, channelId });
|
|
2939
|
-
// 先发送重启反馈消息,等待发送完成后再 kill 进程
|
|
2940
|
-
// 避免消息还没发出去进程就退出了
|
|
2941
|
-
const adapter = this.adapters.get(channel);
|
|
2942
|
-
if (adapter) {
|
|
2943
|
-
try {
|
|
2944
|
-
const envelope = buildEnvelope({
|
|
2945
|
-
taskId: `restart-${Date.now()}`,
|
|
2946
|
-
channel,
|
|
2947
|
-
channelId,
|
|
2948
|
-
agentName: 'system',
|
|
2949
|
-
chatmode: 'interactive',
|
|
2950
|
-
replyContext,
|
|
2951
|
-
});
|
|
2952
|
-
await adapter.send(envelope, { kind: 'command.result', text: '🔄 服务正在重启,请稍候...(约 5 秒后恢复)' });
|
|
2953
|
-
// 等待消息发送完成后再延迟 kill
|
|
2954
|
-
await new Promise(resolve => setTimeout(resolve, 500));
|
|
2955
|
-
}
|
|
2956
|
-
catch (err) {
|
|
2957
|
-
logger.error('[System] Failed to send restart notification:', err);
|
|
2958
|
-
}
|
|
2959
|
-
}
|
|
2960
|
-
// 发 SIGTERM 而非直接 process.exit(0),让 index.ts 的 shutdown() 先
|
|
2961
|
-
// 正常关闭所有 channel(包括 Feishu WebSocket close frame),
|
|
2962
|
-
// 避免 Feishu 服务端因连接异常断开而重推未 ack 的消息给新进程。
|
|
2963
|
-
setTimeout(() => {
|
|
2964
|
-
logger.info('[System] Restarting by user command...');
|
|
2965
|
-
process.kill(process.pid, 'SIGTERM');
|
|
2966
|
-
}, 1000);
|
|
2967
|
-
return true;
|
|
2968
|
-
};
|
|
2969
|
-
// 文本确认流程
|
|
2970
|
-
if (sessionsWithMessages.length > 0) {
|
|
2971
|
-
const restartKey = `${channel}-${channelId}`;
|
|
2972
|
-
const restartConfirmFile = path.join(resolvePaths().dataDir, `restart-confirm-${restartKey}.json`);
|
|
2973
|
-
if (fs.existsSync(restartConfirmFile)) {
|
|
2974
|
-
const confirmInfo = JSON.parse(fs.readFileSync(restartConfirmFile, 'utf-8'));
|
|
2975
|
-
const now = Date.now();
|
|
2976
|
-
if (now - confirmInfo.timestamp < 10000) {
|
|
2977
|
-
fs.unlinkSync(restartConfirmFile);
|
|
2978
|
-
}
|
|
2979
|
-
else {
|
|
2980
|
-
fs.writeFileSync(restartConfirmFile, JSON.stringify({ timestamp: now }));
|
|
2981
|
-
return { kind: 'command.result', text: sessionsWithMessages.join('\n') + '\n再次输入 /restart 将强制重启。' };
|
|
2982
|
-
}
|
|
2983
|
-
}
|
|
2984
|
-
else {
|
|
2985
|
-
fs.writeFileSync(restartConfirmFile, JSON.stringify({ timestamp: Date.now() }));
|
|
2986
|
-
return { kind: 'command.result', text: sessionsWithMessages.join('\n') + '\n再次输入 /restart 将强制重启。' };
|
|
2987
|
-
}
|
|
2988
|
-
}
|
|
2989
|
-
await executeRestart();
|
|
2990
|
-
// executeRestart 内部已经发送了反馈消息,这里返回 null 避免重复发送
|
|
2991
|
-
return null;
|
|
2992
|
-
}
|
|
2993
|
-
// /upgrade 命令:检查版本更新,提示用户手动重启
|
|
2994
|
-
if (normalizedContent === '/upgrade') {
|
|
2995
|
-
if (!isAdmin)
|
|
2996
|
-
return { kind: 'command.error', text: '❌ 无权限:升级检查仅限管理员使用' };
|
|
2997
|
-
if (isLinkedInstall()) {
|
|
2998
|
-
return { kind: 'command.result', text: '⏭ 开发模式,跳过升级检查' };
|
|
2999
|
-
}
|
|
3000
|
-
const localVer = getLocalVersion();
|
|
3001
|
-
const remoteVer = await checkLatestVersion();
|
|
3002
|
-
if (!remoteVer) {
|
|
3003
|
-
return { kind: 'command.result', text: `⚠️ 无法连接 npm registry(当前版本 ${localVer})` };
|
|
3004
|
-
}
|
|
3005
|
-
if (compareVersions(localVer, remoteVer) >= 0) {
|
|
3006
|
-
return { kind: 'command.result', text: `✓ 已是最新版本 (${localVer})` };
|
|
3007
|
-
}
|
|
3008
|
-
return { kind: 'command.result', text: `📦 发现新版本 ${localVer} → ${remoteVer}\n执行 /restart 升级` };
|
|
3009
|
-
}
|
|
3010
|
-
// /pwd 命令:显示当前项目路径
|
|
3011
|
-
if (normalizedContent === '/pwd') {
|
|
3012
|
-
if (!session) {
|
|
3013
|
-
return { kind: 'command.error', text: `❌ 无法创建会话,请检查配置` };
|
|
3014
|
-
}
|
|
3015
|
-
const configName = this.getConfiguredProjectName(session.projectPath);
|
|
3016
|
-
if (configName) {
|
|
3017
|
-
return { kind: 'command.result', text: `当前项目: ${configName}\n路径: ${session.projectPath}` };
|
|
3018
|
-
}
|
|
3019
|
-
return { kind: 'command.result', text: `当前项目: ${session.projectPath}` };
|
|
3020
|
-
}
|
|
3021
|
-
// /file 命令:发送项目内文件,支持 /file path 和 /file channel path(owner only)
|
|
3022
|
-
if (normalizedContent.startsWith('/file')) {
|
|
3023
|
-
if (!isOwner)
|
|
3024
|
-
return { kind: 'command.error', text: '❌ 无权限:此命令仅限 owner 使用' };
|
|
3025
|
-
// 飞书会将 .md 等后缀自动转为 Markdown 链接: foo.md → [foo.md](http://foo.md/)
|
|
3026
|
-
// 还原: 将 [text](url) 替换为 text
|
|
3027
|
-
const rawArg = normalizedContent.slice(5).trim().replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
|
|
3028
|
-
if (!rawArg) {
|
|
3029
|
-
return { kind: 'command.result', text: '用法: /file <相对路径> 或 /file <渠道> <相对路径>\n示例: /file src/index.ts\n示例: /file feishu report.md' };
|
|
3030
|
-
}
|
|
3031
|
-
// 解析目标通道:第一个 token 按实例名匹配,再按 channelType 匹配
|
|
3032
|
-
const tokens = rawArg.split(/\s+/);
|
|
3033
|
-
let targetChannel = channel;
|
|
3034
|
-
let targetLabel = channel;
|
|
3035
|
-
let filePath = rawArg;
|
|
3036
|
-
if (tokens.length >= 2) {
|
|
3037
|
-
const spec = tokens[0];
|
|
3038
|
-
if (this.adapters.has(spec)) {
|
|
3039
|
-
// 精确实例名
|
|
3040
|
-
targetChannel = spec;
|
|
3041
|
-
targetLabel = spec;
|
|
3042
|
-
filePath = tokens.slice(1).join(' ');
|
|
3043
|
-
}
|
|
3044
|
-
else {
|
|
3045
|
-
// 按 channelType 查找第一个匹配的实例
|
|
3046
|
-
for (const [name] of this.adapters) {
|
|
3047
|
-
if ((this.channelTypeMap.get(name) || name) === spec) {
|
|
3048
|
-
targetChannel = name;
|
|
3049
|
-
targetLabel = spec;
|
|
3050
|
-
filePath = tokens.slice(1).join(' ');
|
|
3051
|
-
break;
|
|
3052
|
-
}
|
|
3053
|
-
}
|
|
3054
|
-
}
|
|
3055
|
-
}
|
|
3056
|
-
const isCrossChannel = targetChannel !== channel;
|
|
3057
|
-
// 跨通道仅限 owner
|
|
3058
|
-
if (isCrossChannel && identity.role !== 'owner') {
|
|
3059
|
-
return { kind: 'command.error', text: '❌ 跨通道发送仅限管理员' };
|
|
3060
|
-
}
|
|
3061
|
-
// 找目标 adapter
|
|
3062
|
-
const targetAdapter = this.adapters.get(targetChannel);
|
|
3063
|
-
if (!targetAdapter) {
|
|
3064
|
-
return { kind: 'command.error', text: `❌ 通道 ${targetLabel} 未启用或不存在` };
|
|
3065
|
-
}
|
|
3066
|
-
if (!targetAdapter.capabilities?.file) {
|
|
3067
|
-
return { kind: 'command.error', text: `❌ 通道 ${targetLabel} 不支持文件发送` };
|
|
3068
|
-
}
|
|
3069
|
-
// 获取 session(需要 projectPath)
|
|
3070
|
-
const sendResult = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
3071
|
-
if ('error' in sendResult)
|
|
3072
|
-
return { kind: 'command.result', text: sendResult.error };
|
|
3073
|
-
const sendSession = sendResult.session;
|
|
3074
|
-
// 路径安全校验
|
|
3075
|
-
if (path.isAbsolute(filePath)) {
|
|
3076
|
-
return { kind: 'command.error', text: '❌ 不支持绝对路径\n请使用项目内的相对路径' };
|
|
3077
|
-
}
|
|
3078
|
-
if (filePath.split(path.sep).includes('..') || filePath.split('/').includes('..')) {
|
|
3079
|
-
return { kind: 'command.error', text: '❌ 不支持 .. 路径穿越' };
|
|
3080
|
-
}
|
|
3081
|
-
const resolvedPath = path.resolve(sendSession.projectPath, filePath);
|
|
3082
|
-
// 存在性检查
|
|
3083
|
-
if (!fs.existsSync(resolvedPath)) {
|
|
3084
|
-
return { kind: 'command.error', text: `❌ 文件不存在: ${filePath}` };
|
|
3085
|
-
}
|
|
3086
|
-
// 符号链接安全:realpath 后验证仍在项目目录内
|
|
3087
|
-
const realPath = fs.realpathSync(resolvedPath);
|
|
3088
|
-
const realProjectPath = fs.realpathSync(sendSession.projectPath);
|
|
3089
|
-
if (!realPath.startsWith(realProjectPath + path.sep) && realPath !== realProjectPath) {
|
|
3090
|
-
return { kind: 'command.error', text: '❌ 路径不允许: 文件不在项目目录内' };
|
|
3091
|
-
}
|
|
3092
|
-
const stat = fs.statSync(resolvedPath);
|
|
3093
|
-
if (stat.isDirectory()) {
|
|
3094
|
-
return { kind: 'command.error', text: '❌ 暂不支持发送目录\n目录打包发送将在后续版本支持' };
|
|
3095
|
-
}
|
|
3096
|
-
const MAX_SIZE = 10 * 1024 * 1024;
|
|
3097
|
-
if (stat.size > MAX_SIZE) {
|
|
3098
|
-
return { kind: 'command.error', text: `❌ 文件过大: ${(stat.size / 1024 / 1024).toFixed(1)} MB (限制 10 MB)` };
|
|
3099
|
-
}
|
|
3100
|
-
// 找目标 channelId
|
|
3101
|
-
let targetChannelId = channelId;
|
|
3102
|
-
if (isCrossChannel) {
|
|
3103
|
-
const ownerPeerId = this.agentRegistry?.getOwner?.(targetChannel);
|
|
3104
|
-
targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannel, ownerPeerId) ?? '') : '';
|
|
3105
|
-
if (!targetChannelId) {
|
|
3106
|
-
return { kind: 'command.error', text: `❌ 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息` };
|
|
3107
|
-
}
|
|
3108
|
-
}
|
|
3109
|
-
// 发送文件
|
|
3110
|
-
try {
|
|
3111
|
-
const replyCtx = isCrossChannel ? undefined : this.getReplyContext(sendSession);
|
|
3112
|
-
await targetAdapter.send(buildEnvelope({ channel: targetAdapter.channelName, channelId: targetChannelId, replyContext: replyCtx }), { kind: 'result.file', filePath: realPath });
|
|
3113
|
-
const sizeStr = stat.size < 1024 ? `${stat.size} B`
|
|
3114
|
-
: stat.size < 1024 * 1024 ? `${(stat.size / 1024).toFixed(1)} KB`
|
|
3115
|
-
: `${(stat.size / 1024 / 1024).toFixed(1)} MB`;
|
|
3116
|
-
return { kind: 'command.result', text: isCrossChannel
|
|
3117
|
-
? `📎 文件已通过 ${targetLabel} 发送: ${filePath} (${sizeStr})`
|
|
3118
|
-
: `✅ 已发送: ${filePath} (${sizeStr})` };
|
|
3119
|
-
}
|
|
3120
|
-
catch (error) {
|
|
3121
|
-
logger.error('[CommandHandler] /file failed:', error);
|
|
3122
|
-
return { kind: 'command.error', text: `❌ 文件发送失败: ${error.message || error}` };
|
|
3123
|
-
}
|
|
3124
|
-
}
|
|
3125
|
-
// /slist 命令:列出当前项目的会话
|
|
3126
|
-
// /slist — 仅 EvolClaw 会话
|
|
3127
|
-
// /slist cli — 仅 CLI 会话(未导入的)
|
|
3128
|
-
if (normalizedContent === '/slist' || normalizedContent === '/slist cli') {
|
|
3129
|
-
if (!session) {
|
|
3130
|
-
return { kind: 'command.error', text: `❌ 当前没有活跃会话
|
|
3131
|
-
|
|
3132
|
-
请先执行以下操作之一:
|
|
3133
|
-
1. 发送任意消息 - 自动创建新会话
|
|
3134
|
-
2. /new [名称] - 创建命名会话` };
|
|
3135
|
-
}
|
|
3136
|
-
const showCliOnly = normalizedContent === '/slist cli';
|
|
3137
|
-
// /slist cli — 仅显示 CLI 会话
|
|
3138
|
-
if (showCliOnly) {
|
|
3139
|
-
const canImportCli = policy.canImportCliSession(session.chatType || 'private', identity.role);
|
|
3140
|
-
if (!canImportCli) {
|
|
3141
|
-
return { kind: 'command.error', text: '❌ 当前无权查看 CLI 会话' };
|
|
3142
|
-
}
|
|
3143
|
-
const cliSessions = await this.sessionManager.scanCliSessions(session.projectPath, session.agentId);
|
|
3144
|
-
const sessions = await this.sessionManager.listSessions(channel, channelId);
|
|
3145
|
-
const currentProjectSessions = sessions.filter(s => s.projectPath === session.projectPath && s.agentId === session.agentId && !s.threadId);
|
|
3146
|
-
const dbSessionIds = new Set(currentProjectSessions.map(s => s.agentSessionId).filter(Boolean));
|
|
3147
|
-
const orphanCliSessions = cliSessions.filter(c => !dbSessionIds.has(c.uuid));
|
|
3148
|
-
if (orphanCliSessions.length === 0) {
|
|
3149
|
-
return { kind: 'command.result', text: `当前项目 ${path.basename(session.projectPath)} 没有未导入的 CLI 会话` };
|
|
3150
|
-
}
|
|
3151
|
-
// 构建显示数据(复用于卡片和文本)
|
|
3152
|
-
const cliDisplayItems = orphanCliSessions.map(c => {
|
|
3153
|
-
const time = new Date(c.mtime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
|
3154
|
-
const message = this.sessionManager.readSessionFirstMessage(session.projectPath, c.uuid, session.agentId) || '(无消息)';
|
|
3155
|
-
const uuid = c.uuid.substring(0, 8);
|
|
3156
|
-
return { uuid, fullUuid: c.uuid, time, message };
|
|
3157
|
-
});
|
|
3158
|
-
// 尝试发送 CommandCard 卡片
|
|
3159
|
-
if (this.interactionRouter && cliDisplayItems.length > 0) {
|
|
3160
|
-
const bodyLines = cliDisplayItems.map(item => `• ${item.time} (${item.uuid}) "${item.message}"`);
|
|
3161
|
-
const interaction = {
|
|
3162
|
-
type: 'interaction',
|
|
3163
|
-
id: `slist-cli-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`,
|
|
3164
|
-
channelId,
|
|
3165
|
-
sessionId: session.id,
|
|
3166
|
-
initiatorId: userId,
|
|
3167
|
-
kind: {
|
|
3168
|
-
kind: 'command-card',
|
|
3169
|
-
title: `📋 ${path.basename(session.projectPath)} CLI 会话 (${cliDisplayItems.length})`,
|
|
3170
|
-
body: bodyLines.join('\n'),
|
|
3171
|
-
buttons: cliDisplayItems.map(item => ({
|
|
3172
|
-
label: item.uuid,
|
|
3173
|
-
command: `/session ${item.uuid}`,
|
|
3174
|
-
style: 'default',
|
|
3175
|
-
})),
|
|
3176
|
-
},
|
|
3177
|
-
};
|
|
3178
|
-
const replyCtx = this.getReplyContext(session);
|
|
3179
|
-
const cardResult = await this.sendCommandCard({ channel, channelId, interaction, replyCtx });
|
|
3180
|
-
if (cardResult === null)
|
|
3181
|
-
return null;
|
|
3182
|
-
return { kind: 'command.result', text: cardResult };
|
|
3183
|
-
}
|
|
3184
|
-
// 降级:文本列表
|
|
3185
|
-
const lines = [`当前项目 ${path.basename(session.projectPath)} 的 CLI 会话 (共 ${orphanCliSessions.length} 个):`, ''];
|
|
3186
|
-
for (const item of cliDisplayItems) {
|
|
3187
|
-
lines.push(` ${item.time} (${item.uuid}) "${item.message}"`);
|
|
3188
|
-
}
|
|
3189
|
-
lines.push('');
|
|
3190
|
-
lines.push('使用 /s <8位uuid> 导入并切换到 CLI 会话');
|
|
3191
|
-
return { kind: 'command.result', text: lines.join('\n') };
|
|
3192
|
-
}
|
|
3193
|
-
// /slist — 仅显示 EvolClaw 会话
|
|
3194
|
-
const sessions = await this.sessionManager.listSessions(channel, channelId);
|
|
3195
|
-
const currentProjectSessions = sessions.filter(s => s.projectPath === session.projectPath && s.agentId === session.agentId && !s.threadId);
|
|
3196
|
-
// 从 SDK 同步会话名称(发现 CLI 改名)
|
|
3197
|
-
try {
|
|
3198
|
-
const sdkSessions = await this.sessionManager.listSdkSessions(session.projectPath, session.agentId);
|
|
3199
|
-
for (const sdkSession of sdkSessions) {
|
|
3200
|
-
if (!sdkSession.title)
|
|
3201
|
-
continue;
|
|
3202
|
-
const dbSession = currentProjectSessions.find(s => s.agentSessionId === sdkSession.sessionId);
|
|
3203
|
-
if (dbSession && sdkSession.title !== dbSession.name) {
|
|
3204
|
-
await this.sessionManager.renameSession(dbSession.id, sdkSession.title);
|
|
3205
|
-
dbSession.name = sdkSession.title;
|
|
3206
|
-
}
|
|
3207
|
-
}
|
|
3208
|
-
}
|
|
3209
|
-
catch (error) {
|
|
3210
|
-
logger.debug('[CommandHandler] SDK listSessions sync failed (non-critical):', error);
|
|
3211
|
-
}
|
|
3212
|
-
// 构建可显示会话列表(复用于卡片和文本)
|
|
3213
|
-
const maxDisplay = 10;
|
|
3214
|
-
const displaySessions = [];
|
|
3215
|
-
let displayIndex = 0;
|
|
3216
|
-
for (let i = 0; i < currentProjectSessions.length; i++) {
|
|
3217
|
-
const s = currentProjectSessions[i];
|
|
3218
|
-
if (displayIndex >= maxDisplay)
|
|
3219
|
-
break;
|
|
3220
|
-
const isActive = s.metadata?.isActive === true;
|
|
3221
|
-
displayIndex++;
|
|
3222
|
-
const name = displaySessionTitle(s.name, '(未命名)');
|
|
3223
|
-
const idleTime = formatIdleTime(Date.now() - s.updatedAt);
|
|
3224
|
-
const fileMissing = !!(s.agentSessionId && !this.sessionManager.checkSessionFileExists(s.projectPath, s.agentSessionId, s.agentId));
|
|
3225
|
-
let status = '[空闲]';
|
|
3226
|
-
if (fileMissing) {
|
|
3227
|
-
status = '[会话文件缺失]';
|
|
3228
|
-
}
|
|
3229
|
-
else if (!!s.processingState) {
|
|
3230
|
-
status = '[处理中]';
|
|
3231
|
-
}
|
|
3232
|
-
else if (isActive) {
|
|
3233
|
-
status = '[活跃]';
|
|
3234
|
-
}
|
|
3235
|
-
displaySessions.push({ session: s, index: displayIndex, isActive, name, status, idleTime, fileMissing });
|
|
3236
|
-
}
|
|
3237
|
-
// 尝试发送 CommandCard 卡片(每个会话一个按钮,一键切换)
|
|
3238
|
-
if (this.interactionRouter && displaySessions.length >= 1) {
|
|
3239
|
-
const bodyLines = displaySessions.map(ds => {
|
|
3240
|
-
const prefix = ds.isActive ? '✓' : '•';
|
|
3241
|
-
const uuid = ds.session.agentSessionId ? `(${ds.session.agentSessionId.substring(0, 8)})` : '';
|
|
3242
|
-
const fileMark = ds.fileMissing ? '❌ ' : '';
|
|
3243
|
-
return `${prefix} ${ds.index}. ${fileMark}**${ds.name}** ${uuid} ${ds.idleTime} ${ds.status}`;
|
|
3244
|
-
});
|
|
3245
|
-
const interaction = {
|
|
3246
|
-
type: 'interaction',
|
|
3247
|
-
id: `slist-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`,
|
|
3248
|
-
channelId,
|
|
3249
|
-
sessionId: session.id,
|
|
3250
|
-
initiatorId: userId,
|
|
3251
|
-
kind: {
|
|
3252
|
-
kind: 'command-card',
|
|
3253
|
-
title: `📋 ${path.basename(session.projectPath)} 会话列表`,
|
|
3254
|
-
body: bodyLines.join('\n'),
|
|
3255
|
-
buttons: displaySessions.map(ds => {
|
|
3256
|
-
const shortId = ds.session.agentSessionId ? ds.session.agentSessionId.substring(0, 8) : ds.name;
|
|
3257
|
-
return {
|
|
3258
|
-
label: ds.isActive ? `✓ ${ds.index}. ${shortId}` : `${ds.index}. ${shortId}`,
|
|
3259
|
-
command: `/session ${ds.index}`,
|
|
3260
|
-
style: (ds.isActive ? 'primary' : 'default'),
|
|
3261
|
-
disabled: ds.isActive,
|
|
3262
|
-
};
|
|
3263
|
-
}),
|
|
3264
|
-
},
|
|
3265
|
-
};
|
|
3266
|
-
const replyCtx = this.getReplyContext(session);
|
|
3267
|
-
const cardResult = await this.sendCommandCard({ channel, channelId, interaction, replyCtx });
|
|
3268
|
-
if (cardResult === null)
|
|
3269
|
-
return null;
|
|
3270
|
-
return { kind: 'command.result', text: cardResult };
|
|
3271
|
-
}
|
|
3272
|
-
// 降级:文本列表
|
|
3273
|
-
const lines = [`当前项目 ${path.basename(session.projectPath)} 的 [${session.agentId}] 会话列表:`, ''];
|
|
3274
|
-
if (currentProjectSessions.length > 0) {
|
|
3275
|
-
for (const ds of displaySessions) {
|
|
3276
|
-
const prefix = ds.isActive ? ' ✓' : ' ';
|
|
3277
|
-
const num = `${ds.index}.`;
|
|
3278
|
-
const uuid = ds.session.agentSessionId ? `(${ds.session.agentSessionId.substring(0, 8)})` : '';
|
|
3279
|
-
if (ds.fileMissing) {
|
|
3280
|
-
lines.push(`${prefix} ${num} ❌ ${ds.name} ${uuid} - ${ds.idleTime} ${ds.status}`);
|
|
3281
|
-
}
|
|
3282
|
-
else {
|
|
3283
|
-
lines.push(`${prefix} ${num} ${ds.name} ${uuid} - ${ds.idleTime} ${ds.status}`);
|
|
3284
|
-
}
|
|
3285
|
-
}
|
|
3286
|
-
const hiddenCount = currentProjectSessions.length - displayIndex;
|
|
3287
|
-
if (hiddenCount > 0) {
|
|
3288
|
-
const parts = [];
|
|
3289
|
-
if (hiddenCount > 0)
|
|
3290
|
-
parts.push(`${hiddenCount} 个更早的会话`);
|
|
3291
|
-
lines.push(`\n (已隐藏 ${parts.join('、')})`);
|
|
3292
|
-
}
|
|
3293
|
-
lines.push('');
|
|
3294
|
-
}
|
|
3295
|
-
lines.push('使用 /s <序号、name或8位uuid> 切换会话');
|
|
3296
|
-
lines.push('使用 /s cli 查看 CLI 会话');
|
|
3297
|
-
return { kind: 'command.result', text: lines.join('\n') };
|
|
3298
|
-
}
|
|
3299
|
-
// /session(无参数):直接复用 /slist 逻辑(含卡片交互)
|
|
3300
|
-
if (normalizedContent === '/session') {
|
|
3301
|
-
const delegated = await this.handle('/slist', channel, channelId, undefined, userId, threadId);
|
|
3302
|
-
return typeof delegated === 'string' ? { kind: 'command.result', text: delegated } : delegated;
|
|
3303
|
-
}
|
|
3304
|
-
// /session cli(= /s cli):列出未导入的 CLI 会话
|
|
3305
|
-
if (normalizedContent === '/session cli') {
|
|
3306
|
-
const delegated = await this.handle('/slist cli', channel, channelId, undefined, userId, threadId);
|
|
3307
|
-
return typeof delegated === 'string' ? { kind: 'command.result', text: delegated } : delegated;
|
|
3308
|
-
}
|
|
3309
|
-
// /session 或 /s 命令:切换会话
|
|
3310
|
-
if (normalizedContent.startsWith('/session ')) {
|
|
3311
|
-
const sessionName = normalizedContent.slice(9).trim();
|
|
3312
|
-
if (!sessionName)
|
|
3313
|
-
return { kind: 'command.result', text: '用法: /s <序号、会话名称或前8位UUID>' };
|
|
3314
|
-
let targetSession = await this.sessionManager.getSessionByName(channel, channelId, sessionName);
|
|
3315
|
-
// 序号切换:纯数字时按 /slist 显示的序号匹配(超过10个时隐藏非活跃话题会话)
|
|
3316
|
-
if (!targetSession && /^\d+$/.test(sessionName) && session) {
|
|
3317
|
-
const idx = parseInt(sessionName, 10);
|
|
3318
|
-
const allSessions = await this.sessionManager.listSessions(channel, channelId);
|
|
3319
|
-
const visibleSessions = allSessions.filter(s => s.projectPath === session.projectPath && s.agentId === session.agentId && !s.threadId);
|
|
3320
|
-
if (idx >= 1 && idx <= visibleSessions.length) {
|
|
3321
|
-
targetSession = visibleSessions[idx - 1];
|
|
3322
|
-
}
|
|
3323
|
-
else {
|
|
3324
|
-
return { kind: 'command.error', text: `❌ 序号超出范围 (1-${visibleSessions.length})\n使用 /s 查看可用会话` };
|
|
3325
|
-
}
|
|
3326
|
-
}
|
|
3327
|
-
if (!targetSession && sessionName.length >= 8) {
|
|
3328
|
-
targetSession = await this.sessionManager.getSessionByUuidPrefix(channel, channelId, sessionName);
|
|
3329
|
-
}
|
|
3330
|
-
if (targetSession?.threadId) {
|
|
3331
|
-
return { kind: 'command.error', text: `❌ 话题会话不支持通过 /s 切换\n请在对应话题内继续对话` };
|
|
3332
|
-
}
|
|
3333
|
-
const canImport = policy.canImportCliSession(session?.chatType || 'private', identity.role);
|
|
3334
|
-
if (!targetSession && sessionName.length >= 8 && canImport) {
|
|
3335
|
-
const projectPaths = Object.values(this.projects);
|
|
3336
|
-
if (session) {
|
|
3337
|
-
projectPaths.unshift(session.projectPath);
|
|
3338
|
-
}
|
|
3339
|
-
for (const projectPath of projectPaths) {
|
|
3340
|
-
const currentAgentId = session?.agentId || this.primaryRunnerKey;
|
|
3341
|
-
const cliSessions = await this.sessionManager.scanCliSessions(projectPath, currentAgentId);
|
|
3342
|
-
const cliSession = cliSessions.find(c => c.uuid.startsWith(sessionName));
|
|
3343
|
-
if (cliSession) {
|
|
3344
|
-
const imported = await this.sessionManager.importCliSession(channel, channelId, projectPath, cliSession.uuid, currentAgentId);
|
|
3345
|
-
this.eventBus.publish({ type: 'session:imported', sessionId: imported.id, agentSessionId: cliSession.uuid, projectPath });
|
|
3346
|
-
const projectName = this.getProjectName(projectPath);
|
|
3347
|
-
return { kind: 'command.result', text: `✓ 已导入 CLI 会话: ${displaySessionTitle(imported.name, '(未命名)')}\n 项目: ${projectName}\n 将继续之前的对话历史` };
|
|
3348
|
-
}
|
|
3349
|
-
}
|
|
3350
|
-
}
|
|
3351
|
-
if (!targetSession) {
|
|
3352
|
-
return { kind: 'command.error', text: `❌ 会话不存在: ${sessionName}\n使用 /s 查看可用会话` };
|
|
3353
|
-
}
|
|
3354
|
-
const lastInput = targetSession.agentSessionId
|
|
3355
|
-
? this.sessionManager.readSessionLastUserMessage(targetSession.projectPath, targetSession.agentSessionId, targetSession.agentId)
|
|
3356
|
-
: null;
|
|
3357
|
-
const lastInputLine = lastInput ? `\n 最后输入: "${lastInput}"` : '';
|
|
3358
|
-
if (!session) {
|
|
3359
|
-
const switched = await this.sessionManager.switchToSession(channel, channelId, targetSession.id);
|
|
3360
|
-
if (!switched) {
|
|
3361
|
-
return { kind: 'command.error', text: `❌ 切换会话失败` };
|
|
3362
|
-
}
|
|
3363
|
-
if (source === 'card-trigger')
|
|
3364
|
-
return null;
|
|
3365
|
-
return { kind: 'command.result', text: `✓ 已切换到会话: ${displaySessionTitle(targetSession.name, sessionName)}\n 项目: ${path.basename(targetSession.projectPath)}${lastInputLine}` };
|
|
3366
|
-
}
|
|
3367
|
-
if (targetSession.id === session.id) {
|
|
3368
|
-
return { kind: 'command.result', text: `当前已在会话: ${displaySessionTitle(targetSession.name, sessionName)}` };
|
|
3369
|
-
}
|
|
3370
|
-
// 阻止从主会话切换到话题会话
|
|
3371
|
-
if (!session.threadId && targetSession.threadId) {
|
|
3372
|
-
return { kind: 'command.error', text: `❌ 无法从主会话切换到话题会话\n话题会话仅在对应话题内可用` };
|
|
3373
|
-
}
|
|
3374
|
-
const switched = await this.sessionManager.switchToSession(channel, channelId, targetSession.id);
|
|
3375
|
-
if (!switched) {
|
|
3376
|
-
return { kind: 'command.error', text: `❌ 切换会话失败` };
|
|
3377
|
-
}
|
|
3378
|
-
this.eventBus.publish({ type: 'session:switched', sessionId: targetSession.id, fromSessionId: session.id, toSessionId: targetSession.id });
|
|
3379
|
-
const continueHint = lastInput ? '\n 将继续之前的对话历史' : '\n 当前会话未有发言';
|
|
3380
|
-
if (source === 'card-trigger')
|
|
3381
|
-
return null;
|
|
3382
|
-
return { kind: 'command.result', text: `✓ 已切换到会话: ${displaySessionTitle(targetSession.name, sessionName)}${continueHint}${lastInputLine}` };
|
|
3383
|
-
}
|
|
3384
|
-
// /rename 或 /name 命令:重命名当前会话
|
|
3385
|
-
if (normalizedContent === '/rename' || normalizedContent === '/name') {
|
|
3386
|
-
return { kind: 'command.result', text: '用法: /name <新名称> 或 /rename <新名称>' };
|
|
3387
|
-
}
|
|
3388
|
-
if (normalizedContent.startsWith('/rename ')) {
|
|
3389
|
-
const newName = normalizedContent.slice(8).trim();
|
|
3390
|
-
if (!newName)
|
|
3391
|
-
return { kind: 'command.result', text: '用法: /name <新名称> 或 /rename <新名称>' };
|
|
3392
|
-
if (!session) {
|
|
3393
|
-
return { kind: 'command.error', text: `❌ 当前没有活跃会话
|
|
3394
|
-
|
|
3395
|
-
请先执行以下操作之一:
|
|
3396
|
-
1. 发送任意消息 - 自动创建新会话
|
|
3397
|
-
2. /new [名称] - 创建命名会话
|
|
3398
|
-
3. /session <名称> - 切换到已有会话` };
|
|
3399
|
-
}
|
|
3400
|
-
const existing = await this.sessionManager.getSessionByName(channel, channelId, newName);
|
|
3401
|
-
if (existing && existing.id !== session.id) {
|
|
3402
|
-
return { kind: 'command.error', text: `❌ 会话名称 "${newName}" 已存在,请使用其他名称` };
|
|
3403
|
-
}
|
|
3404
|
-
const oldName = displaySessionTitle(session.name, '(未命名)');
|
|
3405
|
-
const success = await this.sessionManager.renameSession(session.id, newName);
|
|
3406
|
-
if (success && session.agentSessionId) {
|
|
3407
|
-
const renameAgent = this.getAgent(channel, session.agentId);
|
|
3408
|
-
await renameAgent.setSessionName?.(session.agentSessionId, newName).catch(error => {
|
|
3409
|
-
logger.debug('[CommandHandler] Backend session rename sync failed:', error);
|
|
3410
|
-
});
|
|
3411
|
-
}
|
|
3412
|
-
if (!success) {
|
|
3413
|
-
return { kind: 'command.error', text: `❌ 重命名失败` };
|
|
3414
|
-
}
|
|
3415
|
-
this.eventBus.publish({ type: 'session:renamed', sessionId: session.id, oldName, newName });
|
|
3416
|
-
return { kind: 'command.result', text: `✓ 已将当前会话重命名为: ${newName}` };
|
|
3417
|
-
}
|
|
3418
|
-
// /del 命令:删除指定会话(仅解绑,不删除文件)
|
|
3419
|
-
if (normalizedContent.startsWith('/del ')) {
|
|
3420
|
-
const sessionName = normalizedContent.slice(5).trim();
|
|
3421
|
-
if (!sessionName)
|
|
3422
|
-
return { kind: 'command.result', text: '用法: /del <序号、会话名称或前8位UUID>' };
|
|
3423
|
-
if (!session) {
|
|
3424
|
-
return { kind: 'command.error', text: `❌ 当前没有活跃会话` };
|
|
3425
|
-
}
|
|
3426
|
-
// 权限检查:policy 控制谁可以删除会话
|
|
3427
|
-
if (!policy.canDeleteSession(session.chatType || 'private', identity.role)) {
|
|
3428
|
-
return { kind: 'command.error', text: `❌ 无权限:群聊中仅管理员可删除会话` };
|
|
3429
|
-
}
|
|
3430
|
-
let targetSession = await this.sessionManager.getSessionByName(channel, channelId, sessionName);
|
|
3431
|
-
// 序号删除(与 /slist 显示序号一致)
|
|
3432
|
-
if (!targetSession && /^\d+$/.test(sessionName)) {
|
|
3433
|
-
const idx = parseInt(sessionName, 10);
|
|
3434
|
-
const allSessions = await this.sessionManager.listSessions(channel, channelId);
|
|
3435
|
-
const visibleSessions = allSessions.filter(s => s.projectPath === session.projectPath && s.agentId === session.agentId && !s.threadId);
|
|
3436
|
-
if (idx >= 1 && idx <= visibleSessions.length) {
|
|
3437
|
-
targetSession = visibleSessions[idx - 1];
|
|
3438
|
-
}
|
|
3439
|
-
else {
|
|
3440
|
-
return { kind: 'command.error', text: `❌ 序号超出范围 (1-${visibleSessions.length})\n使用 /s 查看可用会话` };
|
|
3441
|
-
}
|
|
3442
|
-
}
|
|
3443
|
-
if (!targetSession && sessionName.length >= 8) {
|
|
3444
|
-
targetSession = await this.sessionManager.getSessionByUuidPrefix(channel, channelId, sessionName);
|
|
3445
|
-
}
|
|
3446
|
-
if (targetSession?.threadId) {
|
|
3447
|
-
return { kind: 'command.error', text: `❌ 请使用话题管理删除话题会话` };
|
|
3448
|
-
}
|
|
3449
|
-
if (!targetSession) {
|
|
3450
|
-
return { kind: 'command.error', text: `❌ 会话不存在: ${sessionName}\n使用 /s 查看可用会话` };
|
|
3451
|
-
}
|
|
3452
|
-
if (targetSession.id === session.id) {
|
|
3453
|
-
return { kind: 'command.error', text: `❌ 无法删除当前活跃会话\n请先切换到其他会话` };
|
|
3454
|
-
}
|
|
3455
|
-
const success = await this.sessionManager.unbindSession(targetSession.id);
|
|
3456
|
-
if (!success) {
|
|
3457
|
-
return { kind: 'command.error', text: `❌ 删除失败` };
|
|
3458
|
-
}
|
|
3459
|
-
this.eventBus.publish({ type: 'session:deleted', sessionId: targetSession.id });
|
|
3460
|
-
const targetAgent = this.getAgent(channel, targetSession.agentId);
|
|
3461
|
-
await targetAgent.closeSession(targetSession.id);
|
|
3462
|
-
return { kind: 'command.result', text: `✓ 已删除会话: ${displaySessionTitle(targetSession.name, sessionName)}\n会话文件已保留,可通过 CLI 访问` };
|
|
3463
|
-
}
|
|
3464
|
-
// /fork 命令:分支当前会话
|
|
3465
|
-
if (normalizedContent === '/fork' || normalizedContent.startsWith('/fork ')) {
|
|
3466
|
-
const forkName = normalizedContent.slice(5).trim() || undefined;
|
|
3467
|
-
if (!session) {
|
|
3468
|
-
return { kind: 'command.error', text: `❌ 当前没有活跃会话,无法分支` };
|
|
3469
|
-
}
|
|
3470
|
-
if (!session.agentSessionId) {
|
|
3471
|
-
return { kind: 'command.error', text: `❌ 当前会话尚未初始化对话,无法分支\n\n请先发送一条消息,然后再使用 /fork` };
|
|
3472
|
-
}
|
|
3473
|
-
const forkAgent = this.getAgent(channel, session.agentId);
|
|
3474
|
-
if (!forkAgent.capabilities?.fork) {
|
|
3475
|
-
return { kind: 'command.error', text: `❌ 当前 Agent (${forkAgent.name}) 不支持 /fork\n\n可使用 /new 创建新会话替代` };
|
|
3476
|
-
}
|
|
3477
|
-
try {
|
|
3478
|
-
const forkedSessionId = await forkAgent.forkSession(session.agentSessionId, session.projectPath, forkName);
|
|
3479
|
-
const newSession = await this.sessionManager.createForkedSession(session, forkedSessionId, forkName);
|
|
3480
|
-
await forkAgent.updateSessionMetadata?.(forkedSessionId, {
|
|
3481
|
-
gitInfo: {
|
|
3482
|
-
branch: null,
|
|
3483
|
-
commitHash: null,
|
|
3484
|
-
repositoryUrl: null,
|
|
3485
|
-
},
|
|
3486
|
-
evolclawSessionId: newSession.id,
|
|
3487
|
-
sourceSessionId: session.id,
|
|
3488
|
-
}).catch(error => {
|
|
3489
|
-
logger.debug('[CommandHandler] Backend fork metadata sync failed:', error);
|
|
3490
|
-
});
|
|
3491
|
-
this.eventBus.publish({ type: 'session:forked', sessionId: newSession.id, sourceSessionId: session.id, name: forkName });
|
|
3492
|
-
return { kind: 'command.result', text: `✅ 会话已分支: ${displaySessionTitle(newSession.name, '(未命名)')}\n新会话已激活,可以继续对话\n\n使用 /s 查看所有会话,/s <名称> 切换回原会话` };
|
|
3493
|
-
}
|
|
3494
|
-
catch (error) {
|
|
3495
|
-
logger.error('[CommandHandler] Fork session failed:', error);
|
|
3496
|
-
return { kind: 'command.error', text: `❌ 会话分支失败: ${error instanceof Error ? error.message : '未知错误'}` };
|
|
3497
|
-
}
|
|
3498
|
-
}
|
|
3499
|
-
// /rewind 命令:查看历史 / 回退会话
|
|
3500
|
-
if (normalizedContent === '/rewind' || normalizedContent.startsWith('/rewind ')) {
|
|
3501
|
-
const result = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
3502
|
-
if ('error' in result)
|
|
3503
|
-
return { kind: 'command.error', text: result.error };
|
|
3504
|
-
const { session } = result;
|
|
3505
|
-
const rewindAgent = this.getAgent(channel, session.agentId);
|
|
3506
|
-
if (!session.agentSessionId) {
|
|
3507
|
-
return { kind: 'command.error', text: '❌ 当前会话无历史记录\n\n请先发送一条消息,然后再使用 /rewind' };
|
|
3508
|
-
}
|
|
3509
|
-
if (!rewindAgent.getSessionMessages) {
|
|
3510
|
-
return { kind: 'command.error', text: `❌ 当前 Agent (${rewindAgent.name}) 不支持 /rewind` };
|
|
3511
|
-
}
|
|
3512
|
-
const args = normalizedContent.slice('/rewind'.length).trim();
|
|
3513
|
-
if (!args) {
|
|
3514
|
-
return { kind: 'command.result', text: await this.handleRewindList(session, rewindAgent) };
|
|
3515
|
-
}
|
|
3516
|
-
// 带参(执行回退,会删除文件/改对话)需 admin+
|
|
3517
|
-
if (!isAdmin)
|
|
3518
|
-
return { kind: 'command.error', text: '❌ 无权限:回退操作仅限管理员使用' };
|
|
3519
|
-
const parts = args.split(/\s+/);
|
|
3520
|
-
const turnNum = parseInt(parts[0], 10);
|
|
3521
|
-
if (isNaN(turnNum) || turnNum < 1) {
|
|
3522
|
-
return { kind: 'command.error', text: '❌ 无效轮次,用法:/rewind <N> chat|file|all(撤销第N轮)' };
|
|
3523
|
-
}
|
|
3524
|
-
const mode = parts[1]?.toLowerCase();
|
|
3525
|
-
if (!mode) {
|
|
3526
|
-
return { kind: 'command.error', text: `❌ 请指定回退模式:/rewind ${turnNum} chat | file | all(撤销第${turnNum}轮)` };
|
|
3527
|
-
}
|
|
3528
|
-
if (!['chat', 'file', 'all'].includes(mode)) {
|
|
3529
|
-
return { kind: 'command.error', text: `❌ 无效模式 "${mode}",可选:chat | file | all` };
|
|
3530
|
-
}
|
|
3531
|
-
return { kind: 'command.result', text: await this.handleRewind(session, rewindAgent, turnNum, mode) };
|
|
3532
|
-
}
|
|
3533
|
-
// /repair 命令:检查并修复会话文件
|
|
3534
|
-
if (normalizedContent === '/repair') {
|
|
3535
|
-
const repairResult = await this.ensureSession(channel, channelId, threadId, chatType);
|
|
3536
|
-
if ('error' in repairResult)
|
|
3537
|
-
return { kind: 'command.result', text: repairResult.error };
|
|
3538
|
-
const { session: repairSession } = repairResult;
|
|
3539
|
-
const repairAgent = this.getAgent(channel, repairSession.agentId);
|
|
3540
|
-
const { checkSessionFile, backupSessionFile } = await import('./session/session-file-health.js');
|
|
3541
|
-
try {
|
|
3542
|
-
if (!repairSession.agentSessionId) {
|
|
3543
|
-
await this.sessionManager.resetHealthStatus(repairSession.id);
|
|
3544
|
-
return { kind: 'command.result', text: `✓ 修复完成\n\n修复内容:\n- 未发现问题(新会话)\n- 已重置异常计数器` };
|
|
3545
|
-
}
|
|
3546
|
-
// 通过 agent 定位 session 文件
|
|
3547
|
-
const sessionFile = repairAgent.resolveSessionFile?.(repairSession.agentSessionId, repairSession.projectPath) ?? null;
|
|
3548
|
-
if (!sessionFile) {
|
|
3549
|
-
// 文件不存在(已被删除或从未创建),直接重置
|
|
3550
|
-
await this.sessionManager.resetHealthStatus(repairSession.id);
|
|
3551
|
-
return { kind: 'command.result', text: `✓ 修复完成\n\n修复内容:\n- 会话文件不存在(可能已被清理)\n- 已重置异常计数器` };
|
|
3552
|
-
}
|
|
3553
|
-
const healthCheck = await checkSessionFile(sessionFile);
|
|
3554
|
-
if (healthCheck.corrupt) {
|
|
3555
|
-
const backupPath = await backupSessionFile(sessionFile);
|
|
3556
|
-
const fsPromises = await import('fs/promises');
|
|
3557
|
-
await fsPromises.unlink(sessionFile);
|
|
3558
|
-
await this.sessionManager.updateAgentSessionIdBySessionId(repairSession.id, '');
|
|
3559
|
-
repairAgent.updateSessionId(repairSession.id, '');
|
|
3560
|
-
await this.sessionManager.resetHealthStatus(repairSession.id);
|
|
3561
|
-
return { kind: 'command.result', text: `✓ 修复完成\n\n检测到问题:\n${healthCheck.issues.map((i) => `- ${i}`).join('\n')}\n\n修复操作:\n- 已备份损坏文件\n- 已删除损坏文件\n- 已重置异常计数器\n\n备份位置:${backupPath}` };
|
|
3562
|
-
}
|
|
3563
|
-
if (healthCheck.issues.length > 0) {
|
|
3564
|
-
await this.sessionManager.resetHealthStatus(repairSession.id);
|
|
3565
|
-
return { kind: 'command.error', text: `⚠️ 检测到问题:\n${healthCheck.issues.map((i) => `- ${i}`).join('\n')}\n\n建议使用 /new 创建新会话\n\n已重置异常计数器,可继续使用当前会话。` };
|
|
3566
|
-
}
|
|
3567
|
-
await this.sessionManager.resetHealthStatus(repairSession.id);
|
|
3568
|
-
return { kind: 'command.result', text: `✓ 修复完成\n\n修复内容:\n- 未发现问题\n- 已重置异常计数器` };
|
|
3569
|
-
}
|
|
3570
|
-
catch (error) {
|
|
3571
|
-
logger.error('[Repair] Failed:', error);
|
|
3572
|
-
return { kind: 'command.error', text: `❌ 修复失败: ${error.message}` };
|
|
3573
|
-
}
|
|
3574
|
-
}
|
|
3575
|
-
// /safe 命令:安全模式已禁用
|
|
3576
|
-
if (normalizedContent === '/safe') {
|
|
3577
|
-
return { kind: 'command.result', text: `ℹ️ 安全模式已禁用\n\n如需重置会话,请使用 /new 创建新会话。` };
|
|
3578
|
-
}
|
|
3579
|
-
// /trigger 命令
|
|
3580
|
-
if (normalizedContent === '/trigger' || normalizedContent.startsWith('/trigger ')) {
|
|
3581
|
-
const text = await this.handleTrigger(normalizedContent, channel, channelId, userId ?? '', isAdmin, messageId);
|
|
3582
|
-
return { kind: 'command.result', text };
|
|
3583
|
-
}
|
|
3584
|
-
return null;
|
|
3585
|
-
}
|
|
3586
|
-
async handleTrigger(content, channel, channelId, peerId, isAdmin, messageId) {
|
|
3587
|
-
// Resolve trigger manager/scheduler from the owning agent of this channel
|
|
3588
|
-
const owningAgent = this.getOwningAgent(channel);
|
|
3589
|
-
const scheduler = (owningAgent?.triggerScheduler ?? this.triggerScheduler);
|
|
3590
|
-
const manager = (owningAgent?.triggerManager ?? this.triggerManager);
|
|
3591
|
-
// Bare /trigger → list active
|
|
3592
|
-
if (content === '/trigger') {
|
|
3593
|
-
if (!manager)
|
|
3594
|
-
return '⚠️ 触发器功能未启用';
|
|
3595
|
-
const active = manager.listActive();
|
|
3596
|
-
if (active.length === 0)
|
|
3597
|
-
return '📭 当前没有活跃的触发器';
|
|
3598
|
-
const lines = active.map(t => {
|
|
3599
|
-
const next = new Date(t.nextFireAt).toLocaleString();
|
|
3600
|
-
const fired = t.fireCount > 0 ? ` | 已触发 ${t.fireCount} 次` : '';
|
|
3601
|
-
return `• **${t.name}** [${t.scheduleType}] 下次: ${next}${fired}`;
|
|
3602
|
-
});
|
|
3603
|
-
return `📋 活跃触发器(${active.length} 个):\n\n${lines.join('\n')}`;
|
|
3604
|
-
}
|
|
3605
|
-
const sub = content.slice('/trigger '.length).trim();
|
|
3606
|
-
// /trigger list → list all (active + history)
|
|
3607
|
-
if (sub === 'list' || sub.startsWith('list ')) {
|
|
3608
|
-
if (!manager)
|
|
3609
|
-
return '⚠️ 触发器功能未启用';
|
|
3610
|
-
const { active, history } = manager.listAll();
|
|
3611
|
-
const lines = [];
|
|
3612
|
-
if (active.length > 0) {
|
|
3613
|
-
lines.push(`**活跃 (${active.length})**`);
|
|
3614
|
-
for (const t of active) {
|
|
3615
|
-
const next = new Date(t.nextFireAt).toLocaleString();
|
|
3616
|
-
lines.push(`• ${t.name} [${t.scheduleType}] 下次: ${next} | 触发 ${t.fireCount} 次`);
|
|
3617
|
-
}
|
|
3618
|
-
}
|
|
3619
|
-
if (history.length > 0) {
|
|
3620
|
-
lines.push(`\n**历史 (${history.length})**`);
|
|
3621
|
-
for (const h of history.slice(-10)) {
|
|
3622
|
-
const done = new Date(h.doneAt).toLocaleString();
|
|
3623
|
-
lines.push(`• ${h.name} [${h.doneReason}] ${done}`);
|
|
3624
|
-
}
|
|
3625
|
-
}
|
|
3626
|
-
if (lines.length === 0)
|
|
3627
|
-
return '📭 没有触发器记录';
|
|
3628
|
-
return lines.join('\n');
|
|
3629
|
-
}
|
|
3630
|
-
// /trigger cancel <name|id>
|
|
3631
|
-
if (sub.startsWith('cancel ')) {
|
|
3632
|
-
if (!manager || !scheduler)
|
|
3633
|
-
return '⚠️ 触发器功能未启用';
|
|
3634
|
-
const nameOrId = sub.slice('cancel '.length).trim();
|
|
3635
|
-
if (!nameOrId)
|
|
3636
|
-
return '❌ 用法:/trigger cancel <名称>';
|
|
3637
|
-
// Find trigger: non-admin lookup is scoped to (peerId, channel) to avoid info disclosure
|
|
3638
|
-
// Non-admins can cancel by name or by their own trigger's UUID
|
|
3639
|
-
let trigger;
|
|
3640
|
-
if (isAdmin) {
|
|
3641
|
-
trigger = manager.getByName(nameOrId) ?? manager.getById(nameOrId);
|
|
3642
|
-
}
|
|
3643
|
-
else {
|
|
3644
|
-
trigger = manager.getByNameScoped(nameOrId, peerId, channel)
|
|
3645
|
-
?? manager.getByIdScoped(nameOrId, peerId, channel);
|
|
3646
|
-
}
|
|
3647
|
-
if (!trigger) {
|
|
3648
|
-
return isAdmin
|
|
3649
|
-
? `❌ 未找到触发器:${nameOrId}`
|
|
3650
|
-
: `❌ 未找到触发器 "${nameOrId}",或无权限取消`;
|
|
3651
|
-
}
|
|
3652
|
-
manager.moveToDone(trigger.id, 'cancelled');
|
|
3653
|
-
scheduler.cancel(trigger.id);
|
|
3654
|
-
this.eventBus.publish({ type: 'trigger:cancelled', triggerId: trigger.id, name: trigger.name, by: peerId });
|
|
3655
|
-
return `✅ 触发器已取消:**${trigger.name}**`;
|
|
3656
|
-
}
|
|
3657
|
-
// /trigger update <name|id> [--参数...]
|
|
3658
|
-
if (sub.startsWith('update ')) {
|
|
3659
|
-
if (!manager || !scheduler)
|
|
3660
|
-
return '⚠️ 触发器功能未启用';
|
|
3661
|
-
const args = sub.slice('update '.length);
|
|
3662
|
-
const result = parseTriggerUpdate(args);
|
|
3663
|
-
if (!result.ok)
|
|
3664
|
-
return `❌ ${result.error}`;
|
|
3665
|
-
const { nameOrId, value: patch } = result;
|
|
3666
|
-
// Find trigger: non-admin lookup is scoped
|
|
3667
|
-
let trigger;
|
|
3668
|
-
if (isAdmin) {
|
|
3669
|
-
trigger = manager.getByName(nameOrId) ?? manager.getById(nameOrId);
|
|
3670
|
-
}
|
|
3671
|
-
else {
|
|
3672
|
-
trigger = manager.getByNameScoped(nameOrId, peerId, channel)
|
|
3673
|
-
?? manager.getByIdScoped(nameOrId, peerId, channel);
|
|
3674
|
-
}
|
|
3675
|
-
if (!trigger) {
|
|
3676
|
-
return isAdmin
|
|
3677
|
-
? `❌ 未找到触发器:${nameOrId}`
|
|
3678
|
-
: `❌ 未找到触发器 "${nameOrId}",或无权限修改`;
|
|
3679
|
-
}
|
|
3680
|
-
// If schedule changed, recalculate nextFireAt
|
|
3681
|
-
if (patch.scheduleType && patch.scheduleValue) {
|
|
3682
|
-
const now = Date.now();
|
|
3683
|
-
patch.nextFireAt = calcNextFireAt(patch.scheduleType, patch.scheduleValue, now);
|
|
3684
|
-
}
|
|
3685
|
-
// 跨渠道迁移:改了 targetChannel 时必须同步重算 targetChannelType,
|
|
3686
|
-
// 并按 session 策略重新绑定执行会话(与 set 路径保持一致,否则 trigger
|
|
3687
|
-
// 仍按旧 channelType 路由 / 仍绑在旧渠道的 boundSessionId 上)。
|
|
3688
|
-
const effectiveChannel = patch.targetChannel ?? trigger.targetChannel;
|
|
3689
|
-
const effectiveChannelId = patch.targetChannelId ?? trigger.targetChannelId;
|
|
3690
|
-
if (patch.targetChannel) {
|
|
3691
|
-
patch.targetChannelType = this.resolveChannelType(patch.targetChannel);
|
|
3692
|
-
}
|
|
3693
|
-
// 解析最终生效的 session 策略(patch 优先,否则沿用原 trigger)
|
|
3694
|
-
const effStrategy = patch.targetSessionStrategy ?? trigger.targetSessionStrategy;
|
|
3695
|
-
// 渠道或策略变化时,按策略重新绑定会话
|
|
3696
|
-
if (patch.targetChannel || patch.targetSessionStrategy) {
|
|
3697
|
-
if (effStrategy === 'current') {
|
|
3698
|
-
if (patch.targetChannel && patch.targetChannel !== trigger.targetChannel) {
|
|
3699
|
-
return '❌ 跨渠道不支持 --session current,请改用 latest 或 thread';
|
|
3700
|
-
}
|
|
3701
|
-
const active = await this.sessionManager.getActiveSession(effectiveChannel, effectiveChannelId);
|
|
3702
|
-
if (!active)
|
|
3703
|
-
return '❌ 目标渠道当前没有活跃会话,改用 --session latest 或先在该渠道发一条消息';
|
|
3704
|
-
patch.boundSessionId = active.id;
|
|
3705
|
-
}
|
|
3706
|
-
else if (effStrategy === 'thread') {
|
|
3707
|
-
const adapter = this.adapters.get(effectiveChannel);
|
|
3708
|
-
if (!adapter?.capabilities.thread)
|
|
3709
|
-
return '❌ 目标渠道不支持 thread 会话';
|
|
3710
|
-
}
|
|
3711
|
-
else {
|
|
3712
|
-
// latest 策略:清除旧的 boundSessionId(若有),按渠道动态取最新会话
|
|
3713
|
-
if (trigger.boundSessionId)
|
|
3714
|
-
patch.boundSessionId = undefined;
|
|
3715
|
-
}
|
|
3716
|
-
}
|
|
3717
|
-
let updated;
|
|
3718
|
-
try {
|
|
3719
|
-
updated = manager.update(trigger.id, patch);
|
|
3720
|
-
scheduler.update(updated);
|
|
3721
|
-
}
|
|
3722
|
-
catch (err) {
|
|
3723
|
-
return `❌ 更新失败:${err.message}`;
|
|
3724
|
-
}
|
|
3725
|
-
const nextStr = new Date(updated.nextFireAt).toLocaleString();
|
|
3726
|
-
return `✅ 触发器已更新:**${updated.name}**\n下次触发:${nextStr}`;
|
|
3727
|
-
}
|
|
3728
|
-
// /trigger set ...
|
|
3729
|
-
if (sub.startsWith('set ')) {
|
|
3730
|
-
if (!manager || !scheduler)
|
|
3731
|
-
return '⚠️ 触发器功能未启用';
|
|
3732
|
-
const args = sub.slice('set '.length);
|
|
3733
|
-
const result = parseTriggerSet(args);
|
|
3734
|
-
if (!result.ok)
|
|
3735
|
-
return `❌ ${result.error}`;
|
|
3736
|
-
const reg = await this.registerTriggerFromParsed(result.value, channel, channelId, peerId, messageId);
|
|
3737
|
-
if (!reg.ok)
|
|
3738
|
-
return `❌ ${reg.error}`;
|
|
3739
|
-
const nextStr = new Date(reg.trigger.nextFireAt).toLocaleString();
|
|
3740
|
-
return `✅ 触发器已注册:**${reg.trigger.name}**\n下次触发:${nextStr}`;
|
|
3741
|
-
}
|
|
3742
|
-
return `❌ 未知子命令。用法:\n/trigger — 查看活跃触发器\n/trigger list — 查看所有触发器\n/trigger set <参数> — 注册触发器\n/trigger update <名称|ID> <参数> — 修改触发器\n/trigger cancel <名称> — 取消触发器`;
|
|
3743
|
-
}
|
|
3744
|
-
/** 从已解析的 trigger 参数组装 Trigger 并注册。文本路径(handleTrigger)与 menu 路径共用。
|
|
3745
|
-
* parsed 形状 = parseTriggerSet 的 result.value(ParsedTriggerSet)。
|
|
3746
|
-
* 失败 return { ok:false, error };成功 return { ok:true, trigger }。本方法不改变原文本路径行为。 */
|
|
3747
|
-
async registerTriggerFromParsed(parsed, channel, channelId, peerId, messageId) {
|
|
3748
|
-
const owningAgent = this.getOwningAgent(channel);
|
|
3749
|
-
const scheduler = (owningAgent?.triggerScheduler ?? this.triggerScheduler);
|
|
3750
|
-
const manager = (owningAgent?.triggerManager ?? this.triggerManager);
|
|
3751
|
-
if (!manager || !scheduler)
|
|
3752
|
-
return { ok: false, error: '触发器功能未启用' };
|
|
3753
|
-
const now = Date.now();
|
|
3754
|
-
const nextFireAt = calcNextFireAt(parsed.scheduleType, parsed.scheduleValue, now);
|
|
3755
|
-
// Auto-generate name if not provided
|
|
3756
|
-
const name = parsed.name ?? `trigger-${Date.now().toString(36)}`;
|
|
3757
|
-
// Validate target channel exists
|
|
3758
|
-
const targetChannelName = parsed.targetChannel ?? channel;
|
|
3759
|
-
if (parsed.targetChannel && !this.adapters.has(parsed.targetChannel)) {
|
|
3760
|
-
return { ok: false, error: `目标渠道不存在或未启用:${parsed.targetChannel}` };
|
|
3761
|
-
}
|
|
3762
|
-
// Validate channelId format for AUN: must look like an AID (contains '.')
|
|
3763
|
-
const targetChannelType = this.resolveChannelType(targetChannelName);
|
|
3764
|
-
const targetChannelId = parsed.targetChannelId ?? channelId;
|
|
3765
|
-
if (targetChannelType === 'aun' && parsed.targetChannelId && !parsed.targetChannelId.includes('.')) {
|
|
3766
|
-
return { ok: false, error: `AUN 渠道的 --channelid 必须是 AID 格式(如 user.agentid.pub),收到:"${parsed.targetChannelId}"` };
|
|
3767
|
-
}
|
|
3768
|
-
const trigger = {
|
|
3769
|
-
id: crypto.randomUUID(),
|
|
3770
|
-
name,
|
|
3771
|
-
scheduleType: parsed.scheduleType,
|
|
3772
|
-
scheduleValue: parsed.scheduleValue,
|
|
3773
|
-
nextFireAt,
|
|
3774
|
-
targetChannel: targetChannelName,
|
|
3775
|
-
targetChannelId,
|
|
3776
|
-
targetChannelType,
|
|
3777
|
-
targetThreadId: parsed.targetThreadId,
|
|
3778
|
-
targetSessionStrategy: parsed.targetSessionStrategy,
|
|
3779
|
-
agentId: parsed.agentId,
|
|
3780
|
-
prompt: parsed.prompt,
|
|
3781
|
-
createdByPeerId: peerId,
|
|
3782
|
-
createdByChannel: channel,
|
|
3783
|
-
fireCount: 0,
|
|
3784
|
-
failCount: 0,
|
|
3785
|
-
createdAt: now,
|
|
3786
|
-
updatedAt: now,
|
|
3787
|
-
};
|
|
3788
|
-
try {
|
|
3789
|
-
// Strategy-based session binding
|
|
3790
|
-
if (parsed.targetSessionStrategy === 'current') {
|
|
3791
|
-
if (parsed.targetChannel && parsed.targetChannel !== channel) {
|
|
3792
|
-
return { ok: false, error: '跨渠道不支持 --session current,请改用 latest 或 thread' };
|
|
3793
|
-
}
|
|
3794
|
-
const active = await this.sessionManager.getActiveSession(channel, channelId);
|
|
3795
|
-
if (!active)
|
|
3796
|
-
return { ok: false, error: '当前没有活跃会话,改用 --session latest 或 thread' };
|
|
3797
|
-
trigger.boundSessionId = active.id;
|
|
3798
|
-
}
|
|
3799
|
-
else if (parsed.targetSessionStrategy === 'thread') {
|
|
3800
|
-
const targetAdapterName = parsed.targetChannel ?? channel;
|
|
3801
|
-
const adapter = this.adapters.get(targetAdapterName);
|
|
3802
|
-
if (!adapter?.capabilities.thread)
|
|
3803
|
-
return { ok: false, error: '目标渠道不支持 thread 会话' };
|
|
3804
|
-
const channelType = adapter.channelKey.split('#')[0];
|
|
3805
|
-
trigger.targetChannelType = channelType;
|
|
3806
|
-
if (channelType === 'aun') {
|
|
3807
|
-
trigger.threadKind = 'aun';
|
|
3808
|
-
trigger.targetThreadId = `trigger-${trigger.id}`;
|
|
3809
|
-
}
|
|
3810
|
-
else {
|
|
3811
|
-
if (!messageId)
|
|
3812
|
-
return { ok: false, error: '飞书 thread 模式需要消息 ID,请重新发送命令' };
|
|
3813
|
-
trigger.threadKind = 'feishu';
|
|
3814
|
-
trigger.rootMessageId = messageId;
|
|
3815
|
-
trigger.pendingThread = true;
|
|
3816
|
-
}
|
|
3817
|
-
}
|
|
3818
|
-
// Validate name uniqueness before persisting (manager.register writes to disk)
|
|
3819
|
-
// scheduler.register is in-memory only and cannot fail, so order is safe here.
|
|
3820
|
-
// If manager.register throws (duplicate name/ID), nothing is persisted.
|
|
3821
|
-
manager.register(trigger);
|
|
3822
|
-
scheduler.register(trigger);
|
|
3823
|
-
}
|
|
3824
|
-
catch (err) {
|
|
3825
|
-
return { ok: false, error: `注册失败:${err.message}` };
|
|
3826
|
-
}
|
|
3827
|
-
return { ok: true, trigger };
|
|
3828
|
-
}
|
|
3829
|
-
// ── /rewind helpers ──
|
|
3830
|
-
async handleRewindList(session, agent) {
|
|
3831
|
-
try {
|
|
3832
|
-
const messages = await agent.getSessionMessages(session.agentSessionId, session.projectPath);
|
|
3833
|
-
const turns = this.buildTurnList(messages);
|
|
3834
|
-
if (turns.length === 0) {
|
|
3835
|
-
return '📋 当前会话暂无对话记录';
|
|
3836
|
-
}
|
|
3837
|
-
const lines = turns.map(t => `#${t.index} ${t.userContent}`);
|
|
3838
|
-
return [
|
|
3839
|
-
`📋 会话历史 (共 ${turns.length} 轮)`,
|
|
3840
|
-
'',
|
|
3841
|
-
...lines,
|
|
3842
|
-
'',
|
|
3843
|
-
'💡 /rewind <N> chat|file|all — 撤销第N轮',
|
|
3844
|
-
].join('\n');
|
|
3845
|
-
}
|
|
3846
|
-
catch (error) {
|
|
3847
|
-
logger.error('[CommandHandler] Failed to read session messages:', error);
|
|
3848
|
-
return `❌ 读取会话历史失败: ${error instanceof Error ? error.message : '未知错误'}`;
|
|
3849
|
-
}
|
|
3850
|
-
}
|
|
3851
|
-
async handleRewind(session, agent, turnNum, mode) {
|
|
3852
|
-
try {
|
|
3853
|
-
const messages = await agent.getSessionMessages(session.agentSessionId, session.projectPath);
|
|
3854
|
-
const turns = this.buildTurnList(messages);
|
|
3855
|
-
if (turnNum < 1 || turnNum > turns.length) {
|
|
3856
|
-
return `❌ 轮次超出范围,当前共 ${turns.length} 轮`;
|
|
3857
|
-
}
|
|
3858
|
-
// /rewind N = 撤销第N轮(及之后),保留 1..N-1
|
|
3859
|
-
const rewindTarget = turns[turnNum - 1]; // 被撤销的轮次(用于文件回退)
|
|
3860
|
-
const keepTarget = turnNum >= 2 ? turns[turnNum - 2] : null; // 保留到的轮次(用于对话回退)
|
|
3861
|
-
const results = [];
|
|
3862
|
-
// 文件回退(立即执行)
|
|
3863
|
-
if (mode === 'file' || mode === 'all') {
|
|
3864
|
-
if (!agent.rewindFiles) {
|
|
3865
|
-
return '❌ 当前 Agent 不支持文件回退';
|
|
3866
|
-
}
|
|
3867
|
-
const fileResult = await agent.rewindFiles(session.agentSessionId, session.projectPath, rewindTarget.userUuid);
|
|
3868
|
-
if (!fileResult.canRewind) {
|
|
3869
|
-
if (mode === 'file') {
|
|
3870
|
-
return `❌ 当前会话无文件快照,无法回退文件${fileResult.error ? `\n原因: ${fileResult.error}` : ''}`;
|
|
3871
|
-
}
|
|
3872
|
-
results.push(`⚠️ 文件回退失败${fileResult.error ? `: ${fileResult.error}` : '(无文件快照)'}`);
|
|
3873
|
-
}
|
|
3874
|
-
else {
|
|
3875
|
-
const detail = fileResult.filesChanged
|
|
3876
|
-
? `(恢复了 ${fileResult.filesChanged.length} 个文件)`
|
|
3877
|
-
: '';
|
|
3878
|
-
if (agent.capabilities?.fileRewind === 'git-head') {
|
|
3879
|
-
results.push(`✅ 已按 Git HEAD 恢复文件${detail}(Codex 当前不提供逐轮文件快照)`);
|
|
3880
|
-
}
|
|
3881
|
-
else {
|
|
3882
|
-
results.push(`✅ 已恢复文件到第 ${turnNum} 轮之前的状态${detail}`);
|
|
3883
|
-
}
|
|
3884
|
-
}
|
|
3885
|
-
}
|
|
3886
|
-
// 对话回退:Codex app-server 可直接 rollback;Claude 走 resumeAt 延迟到下次消息生效。
|
|
3887
|
-
if (mode === 'chat' || mode === 'all') {
|
|
3888
|
-
const discarded = turns.length - turnNum + 1;
|
|
3889
|
-
if (agent.rollbackSessionTurns) {
|
|
3890
|
-
const ok = await agent.rollbackSessionTurns(session.agentSessionId, session.projectPath, discarded);
|
|
3891
|
-
if (!ok)
|
|
3892
|
-
return '❌ 对话回退失败';
|
|
3893
|
-
const meta = { ...(session.metadata || {}) };
|
|
3894
|
-
delete meta.resumeAt;
|
|
3895
|
-
await this.sessionManager.updateSession(session.id, { metadata: meta });
|
|
3896
|
-
}
|
|
3897
|
-
else if (keepTarget) {
|
|
3898
|
-
const meta = { ...(session.metadata || {}), resumeAt: keepTarget.assistantUuid };
|
|
3899
|
-
await this.sessionManager.updateSession(session.id, { metadata: meta });
|
|
3900
|
-
}
|
|
3901
|
-
else {
|
|
3902
|
-
// N=1:撤销全部对话,清空 session 从头开始
|
|
3903
|
-
const meta = { ...(session.metadata || {}) };
|
|
3904
|
-
delete meta.resumeAt;
|
|
3905
|
-
await this.sessionManager.updateSession(session.id, {
|
|
3906
|
-
metadata: meta,
|
|
3907
|
-
agentSessionId: null,
|
|
3908
|
-
});
|
|
3909
|
-
}
|
|
3910
|
-
results.push(`✅ 已撤销第 ${turnNum} 轮${discarded > 1 ? `及后续共 ${discarded} 轮` : ''}`, keepTarget ? `下次发言将从第 ${turnNum - 1} 轮继续` : '下次发言将开始全新对话');
|
|
3911
|
-
}
|
|
3912
|
-
this.eventBus.publish({
|
|
3913
|
-
type: 'session:rewind',
|
|
3914
|
-
sessionId: session.id,
|
|
3915
|
-
turnNum,
|
|
3916
|
-
mode,
|
|
3917
|
-
});
|
|
3918
|
-
return results.join('\n');
|
|
3919
|
-
}
|
|
3920
|
-
catch (error) {
|
|
3921
|
-
logger.error('[CommandHandler] Rewind failed:', error);
|
|
3922
|
-
return `❌ 回退失败: ${error instanceof Error ? error.message : '未知错误'}`;
|
|
3923
|
-
}
|
|
3924
|
-
}
|
|
3925
|
-
buildTurnList(messages) {
|
|
3926
|
-
const turns = [];
|
|
3927
|
-
let pendingUser = null;
|
|
3928
|
-
for (const msg of messages) {
|
|
3929
|
-
if (msg.type === 'user') {
|
|
3930
|
-
const m = msg.message;
|
|
3931
|
-
if (Array.isArray(m?.content) && m.content.every((c) => c.type === 'tool_result')) {
|
|
3932
|
-
continue;
|
|
3933
|
-
}
|
|
3934
|
-
const content = this.extractUserContent(msg.message);
|
|
3935
|
-
if (content) {
|
|
3936
|
-
pendingUser = { content, uuid: msg.uuid };
|
|
3937
|
-
}
|
|
3938
|
-
}
|
|
3939
|
-
else if (msg.type === 'assistant' && pendingUser) {
|
|
3940
|
-
turns.push({
|
|
3941
|
-
index: turns.length + 1,
|
|
3942
|
-
userContent: pendingUser.content,
|
|
3943
|
-
userUuid: pendingUser.uuid,
|
|
3944
|
-
assistantUuid: msg.uuid,
|
|
3945
|
-
});
|
|
3946
|
-
pendingUser = null;
|
|
3947
|
-
}
|
|
3948
|
-
}
|
|
3949
|
-
return turns;
|
|
3950
|
-
}
|
|
3951
|
-
// ── Agent Ctl ──
|
|
3952
|
-
static CTL_COMMANDS = [
|
|
3953
|
-
'/help', '/status', '/check', '/pwd',
|
|
3954
|
-
'/model', '/effort', '/perm', '/agent', '/baseagent',
|
|
3955
|
-
'/compact', '/file', '/send', '/restart', '/aid', '/rpc', '/storage',
|
|
3956
|
-
'/rename', '/name', '/trigger',
|
|
3957
|
-
'/chatmode', '/dispatch', '/activity',
|
|
3958
|
-
];
|
|
3959
|
-
/** ctl 中仅允许查询形态的指令;写形态(带参)一律拒绝 */
|
|
3960
|
-
static CTL_READONLY = new Set(['/baseagent']);
|
|
3961
|
-
/**
|
|
3962
|
-
* 从 session 恢复 ReplyContext,用于 ctl send 主动发送文本时的路由
|
|
3963
|
-
* - 群聊话题:metadata.replyContext.{threadId,peerId}
|
|
3964
|
-
* - 私聊:metadata.peerId
|
|
3965
|
-
* - taskId/chatmode:从 processing_state 和 sessionMode 注入
|
|
3966
|
-
*/
|
|
3967
|
-
buildCtlReplyContext(session) {
|
|
3968
|
-
const ctx = {};
|
|
3969
|
-
const meta = session.metadata;
|
|
3970
|
-
if (meta?.replyContext?.threadId)
|
|
3971
|
-
ctx.threadId = meta.replyContext.threadId;
|
|
3972
|
-
if (meta?.replyContext?.peerId)
|
|
3973
|
-
ctx.peerId = meta.replyContext.peerId;
|
|
3974
|
-
if (!ctx.peerId && meta?.peerId)
|
|
3975
|
-
ctx.peerId = meta.peerId;
|
|
3976
|
-
const taskId = this.sessionManager.getActiveTaskId(session.id);
|
|
3977
|
-
const chatmode = session.sessionMode || 'interactive';
|
|
3978
|
-
const encrypted = this.sessionManager.getSessionEncrypt(session.id);
|
|
3979
|
-
// 诊断日志:记录 task_id 解析结果
|
|
3980
|
-
logger.info(`[CommandHandler] buildCtlReplyContext: sessionId=${session.id} taskId=${taskId ?? 'none'} chatmode=${chatmode} threadId=${ctx.threadId ?? 'none'}`);
|
|
3981
|
-
if (taskId || chatmode !== 'interactive' || encrypted != null) {
|
|
3982
|
-
ctx.metadata = {};
|
|
3983
|
-
if (taskId)
|
|
3984
|
-
ctx.metadata.taskId = taskId;
|
|
3985
|
-
if (chatmode !== 'interactive')
|
|
3986
|
-
ctx.metadata.chatmode = chatmode;
|
|
3987
|
-
if (encrypted != null)
|
|
3988
|
-
ctx.metadata.encrypted = encrypted;
|
|
3989
|
-
}
|
|
3990
|
-
return Object.keys(ctx).length > 0 ? ctx : undefined;
|
|
3991
|
-
}
|
|
3992
|
-
/**
|
|
3993
|
-
* Agent ctl 入口:通过 IPC 接收 Agent 自主管理指令
|
|
3994
|
-
* 复用现有 slash cmd 逻辑,权限继承 session 用户角色
|
|
3995
|
-
*/
|
|
3996
|
-
async handleCtl(cmd, sessionId) {
|
|
3997
|
-
logger.info(`[ctl] cmd="${cmd}" sessionId=${sessionId}`);
|
|
3998
|
-
// 1. 白名单检查
|
|
3999
|
-
const inputCmd = cmd.split(' ')[0];
|
|
4000
|
-
if (!CommandHandler.CTL_COMMANDS.includes(inputCmd)) {
|
|
4001
|
-
return { ok: false, error: `不允许的指令: ${inputCmd}` };
|
|
4002
|
-
}
|
|
4003
|
-
// 1.1 只读守卫:带参形态(写操作)在 ctl 中禁止
|
|
4004
|
-
if (CommandHandler.CTL_READONLY.has(inputCmd) && cmd.trimEnd().length > inputCmd.length) {
|
|
4005
|
-
return { ok: false, error: `${inputCmd} 在 ctl 中仅支持查询形态,不支持带参切换` };
|
|
4006
|
-
}
|
|
4007
|
-
// 2. 通过 sessionId 查 session
|
|
4008
|
-
const session = await this.sessionManager.getSessionById(sessionId);
|
|
4009
|
-
if (!session) {
|
|
4010
|
-
return { ok: false, error: '无效的 session' };
|
|
4011
|
-
}
|
|
4012
|
-
// 3. 从 session.metadata.peerId 获取 userId(用于权限判断)
|
|
4013
|
-
const userId = session.metadata?.peerId;
|
|
4014
|
-
// 3.1 /agent: EvolAgent 管理(转发到 CLI)
|
|
4015
|
-
if (cmd === '/agent' || cmd.startsWith('/agent ')) {
|
|
4016
|
-
const arg = cmd.slice('/agent'.length).trim();
|
|
4017
|
-
// 无参数时返回用法
|
|
4018
|
-
if (!arg) {
|
|
4019
|
-
return { ok: true, result: `用法:\n /agent list 列出所有 agent\n /agent show [name] 查看 agent 详情\n /agent enable <name> 启用 agent\n /agent disable <name> 停用 agent\n /agent get <name> <key> 读取配置字段\n /agent set <name> <key> <val> 修改配置字段\n /agent rename <name> <newname> 修改名称\n /agent reload [name] 热重载配置` };
|
|
4020
|
-
}
|
|
4021
|
-
const parts = arg.split(/\s+/);
|
|
4022
|
-
const subCmd = parts[0];
|
|
4023
|
-
// ctl 禁止 new/delete(仅限 CLI 操作)
|
|
4024
|
-
if (subCmd === 'new' || subCmd === 'delete') {
|
|
4025
|
-
return { ok: false, error: `❌ /agent ${subCmd} 仅限 CLI 操作,请使用: evolclaw agent ${subCmd} ...` };
|
|
4026
|
-
}
|
|
4027
|
-
// 自我保护:不能 disable 自己所在的 agent
|
|
4028
|
-
const selfAgent = this.getOwningAgent(session.channel);
|
|
4029
|
-
const selfName = selfAgent?.name;
|
|
4030
|
-
if (selfName && subCmd === 'disable' && parts[1] === selfName) {
|
|
4031
|
-
return { ok: false, error: `❌ 不能 disable 自己所在的 agent: ${selfName}` };
|
|
4032
|
-
}
|
|
4033
|
-
// 转发到 CLI
|
|
4034
|
-
const cliArgs = ['agent', ...parts];
|
|
4035
|
-
try {
|
|
4036
|
-
const { execFile } = await import('node:child_process');
|
|
4037
|
-
const { promisify } = await import('node:util');
|
|
4038
|
-
const execFileAsync = promisify(execFile);
|
|
4039
|
-
const { stdout, stderr } = await execFileAsync('evolclaw', cliArgs, {
|
|
4040
|
-
timeout: 30000,
|
|
4041
|
-
encoding: 'utf-8',
|
|
4042
|
-
env: { ...process.env, AUN_LOG_INI_DISABLE: '1' },
|
|
4043
|
-
});
|
|
4044
|
-
const output = (stdout || '').trim();
|
|
4045
|
-
if (!output && stderr)
|
|
4046
|
-
return { ok: true, result: `⚠ ${stderr.trim().slice(0, 500)}` };
|
|
4047
|
-
return { ok: true, result: output || '(无输出)' };
|
|
4048
|
-
}
|
|
4049
|
-
catch (e) {
|
|
4050
|
-
const msg = e.stderr?.trim() || e.stdout?.trim() || String(e.message || e);
|
|
4051
|
-
return { ok: false, error: msg.slice(0, 500) };
|
|
4052
|
-
}
|
|
4053
|
-
}
|
|
4054
|
-
// 4. /send 文本消息:直接通过 adapter 主动发送,不走 handle()
|
|
4055
|
-
if (cmd.startsWith('/send ') || cmd === '/send') {
|
|
4056
|
-
// 解析 --encrypt 标志和消息文本
|
|
4057
|
-
const raw = cmd.startsWith('/send ') ? cmd.slice(6).trim() : '';
|
|
4058
|
-
const forceEncrypt = raw.startsWith('--encrypt ');
|
|
4059
|
-
const text = forceEncrypt ? raw.slice(10).trim() : raw;
|
|
4060
|
-
if (!text)
|
|
4061
|
-
return { ok: false, error: '消息内容不能为空' };
|
|
4062
|
-
const adapter = this.adapters.get(session.channel);
|
|
4063
|
-
if (!adapter)
|
|
4064
|
-
return { ok: false, error: `adapter 未找到: ${session.channel}` };
|
|
4065
|
-
try {
|
|
4066
|
-
const replyContext = this.buildCtlReplyContext(session);
|
|
4067
|
-
const taskId = replyContext?.metadata?.taskId;
|
|
4068
|
-
const chatmode = replyContext?.metadata?.chatmode ?? 'interactive';
|
|
4069
|
-
// --encrypt 覆盖 session 加密状态
|
|
4070
|
-
// 添加 source: 'ctl' 标记(用于区分 ec ctl send)
|
|
4071
|
-
const enrichedReplyContext = forceEncrypt
|
|
4072
|
-
? { ...(replyContext ?? {}), metadata: { ...(replyContext?.metadata ?? {}), encrypted: true, source: 'ctl' } }
|
|
4073
|
-
: { ...(replyContext ?? {}), metadata: { ...(replyContext?.metadata ?? {}), source: 'ctl' } };
|
|
4074
|
-
await adapter.send(buildEnvelope({ taskId, channel: adapter.channelName, channelId: session.channelId, chatmode, replyContext: enrichedReplyContext }), { kind: 'result.text', text, isFinal: true });
|
|
4075
|
-
// 出方向 jsonl 写入已下沉到 aun.ts:deliverTextEntry,message.send 成功后统一写入。
|
|
4076
|
-
return { ok: true, result: 'ok' };
|
|
4077
|
-
}
|
|
4078
|
-
catch (err) {
|
|
4079
|
-
return { ok: false, error: err.message || String(err) };
|
|
4080
|
-
}
|
|
4081
|
-
}
|
|
4082
|
-
// 5. file 路径限制:只允许 projectPath 下的文件
|
|
4083
|
-
if (cmd.startsWith('/file')) {
|
|
4084
|
-
const sendArgs = cmd.slice(5).trim();
|
|
4085
|
-
const parts = sendArgs.split(/\s+/);
|
|
4086
|
-
const filePath = parts[parts.length - 1];
|
|
4087
|
-
if (filePath) {
|
|
4088
|
-
const resolved = path.resolve(session.projectPath, filePath).replace(/\\/g, '/');
|
|
4089
|
-
const projectPath = session.projectPath.replace(/\\/g, '/');
|
|
4090
|
-
if (!resolved.startsWith(projectPath)) {
|
|
4091
|
-
return { ok: false, error: '路径越界:只能发送项目目录下的文件' };
|
|
4092
|
-
}
|
|
4093
|
-
}
|
|
4094
|
-
}
|
|
4095
|
-
// 5.1 /aid, /rpc, /storage — ctl 专属,转发到 CLI 执行
|
|
4096
|
-
if (cmd === '/aid' || cmd.startsWith('/aid ') ||
|
|
4097
|
-
cmd === '/rpc' || cmd.startsWith('/rpc ') ||
|
|
4098
|
-
cmd === '/storage' || cmd.startsWith('/storage ')) {
|
|
4099
|
-
// 权限检查:仅 owner
|
|
4100
|
-
if (userId) {
|
|
4101
|
-
const identity = this.sessionManager.resolveIdentity(session.channel, userId);
|
|
4102
|
-
if (identity.role !== 'owner') {
|
|
4103
|
-
return { ok: false, error: '无权限:此命令仅限 owner 使用' };
|
|
4104
|
-
}
|
|
4105
|
-
}
|
|
4106
|
-
// 无参数时返回用法说明
|
|
4107
|
-
if (cmd === '/aid') {
|
|
4108
|
-
return { ok: true, result: `用法:\n /aid list 列出本地所有 AID\n /aid show <aid> 查看 AID 详情\n /aid new <aid> 创建新 AID\n /aid delete <aid> 删除本地 AID\n /aid lookup <aid> 远程探测 AID\n /aid agentmd put <aid> 签名并上传 agent.md\n /aid agentmd get <aid> 下载并验签 agent.md` };
|
|
4109
|
-
}
|
|
4110
|
-
if (cmd === '/rpc') {
|
|
4111
|
-
return { ok: true, result: `用法: /rpc --as <aid> --params <json>\n示例: /rpc --as myaid.agentid.pub --params {"method":"meta.ping","params":{}}` };
|
|
4112
|
-
}
|
|
4113
|
-
if (cmd === '/storage') {
|
|
4114
|
-
return { ok: true, result: `用法:\n /storage upload <aid> <file> <path> [--public]\n /storage download <aid> <url> [local-path]\n /storage ls <aid> [prefix]\n /storage rm <aid> <path>\n /storage quota <aid>` };
|
|
4115
|
-
}
|
|
4116
|
-
// /aid 自我保护:不能删除当前 agent 所用的 AID
|
|
4117
|
-
if (cmd.startsWith('/aid delete ')) {
|
|
4118
|
-
const targetAid = cmd.slice('/aid delete '.length).trim();
|
|
4119
|
-
const selfAgent = this.getOwningAgent(session.channel);
|
|
4120
|
-
const selfAid = selfAgent?.config?.aid;
|
|
4121
|
-
if (selfAid && targetAid === selfAid) {
|
|
4122
|
-
return { ok: false, error: `❌ 不能删除当前 agent 所用的 AID: ${selfAid}` };
|
|
4123
|
-
}
|
|
4124
|
-
}
|
|
4125
|
-
const cliArgs = cmd.slice(1); // strip leading /
|
|
4126
|
-
try {
|
|
4127
|
-
const { execFile } = await import('node:child_process');
|
|
4128
|
-
const { promisify } = await import('node:util');
|
|
4129
|
-
const execFileAsync = promisify(execFile);
|
|
4130
|
-
const { stdout, stderr } = await execFileAsync('evolclaw', cliArgs.split(/\s+/), {
|
|
4131
|
-
timeout: 30000,
|
|
4132
|
-
encoding: 'utf-8',
|
|
4133
|
-
env: { ...process.env, AUN_LOG_INI_DISABLE: '1' },
|
|
4134
|
-
});
|
|
4135
|
-
const output = (stdout || '').trim();
|
|
4136
|
-
if (!output && stderr)
|
|
4137
|
-
return { ok: true, result: `⚠ ${stderr.trim().slice(0, 500)}` };
|
|
4138
|
-
return { ok: true, result: output || '(无输出)' };
|
|
4139
|
-
}
|
|
4140
|
-
catch (e) {
|
|
4141
|
-
const msg = e.stderr?.trim() || e.stdout?.trim() || String(e.message || e);
|
|
4142
|
-
return { ok: false, error: msg.slice(0, 500) };
|
|
4143
|
-
}
|
|
4144
|
-
}
|
|
4145
|
-
// 6. 调用现有 handle(),不传 sendMessage 回调(结果直接返回)
|
|
4146
|
-
try {
|
|
4147
|
-
const result = await this.handle(cmd, session.channel, session.channelId, undefined, // 不发送消息
|
|
4148
|
-
userId);
|
|
4149
|
-
const text = typeof result === 'string' ? result : (result && 'text' in result ? result.text : '(无输出)');
|
|
4150
|
-
return { ok: true, result: text || '(无输出)' };
|
|
4151
|
-
}
|
|
4152
|
-
catch (err) {
|
|
4153
|
-
return { ok: false, error: err.message };
|
|
4154
|
-
}
|
|
4155
|
-
}
|
|
4156
|
-
extractUserContent(message) {
|
|
4157
|
-
const m = message;
|
|
4158
|
-
let text = '';
|
|
4159
|
-
if (typeof m?.content === 'string') {
|
|
4160
|
-
text = m.content;
|
|
4161
|
-
}
|
|
4162
|
-
else if (Array.isArray(m?.content)) {
|
|
4163
|
-
text = m.content.filter((b) => b.type === 'text').map((b) => b.text).join(' ');
|
|
4164
|
-
}
|
|
4165
|
-
text = text.trim();
|
|
4166
|
-
// Strip injection wrappers before previewing (outermost first):
|
|
4167
|
-
// 1. Interrupt wrapper: 【新消息插入】\n...\n【请无视之前中断继续处理】
|
|
4168
|
-
text = text.replace(/^【新消息插入】\s*/, '').replace(/\s*【请无视之前中断继续处理】$/, '').trim();
|
|
4169
|
-
// 2. Current format: ‹metadata›\ncontent (message-renderer item.md)
|
|
4170
|
-
if (text.startsWith('‹')) {
|
|
4171
|
-
const nl = text.indexOf('\n');
|
|
4172
|
-
if (nl !== -1)
|
|
4173
|
-
text = text.slice(nl + 1).trim();
|
|
4174
|
-
}
|
|
4175
|
-
// 3. Legacy XML format: <messages><message sender="..." time="...">content</message></messages>
|
|
4176
|
-
if (text.startsWith('<messages>')) {
|
|
4177
|
-
const parts = [];
|
|
4178
|
-
const re = /<message(?:\s[^>]*)?>([\s\S]*?)<\/message>/g;
|
|
4179
|
-
let match;
|
|
4180
|
-
while ((match = re.exec(text)) !== null)
|
|
4181
|
-
parts.push(match[1].trim());
|
|
4182
|
-
if (parts.length > 0)
|
|
4183
|
-
text = parts.join(' ');
|
|
4184
|
-
}
|
|
4185
|
-
text = text.replace(/\s+/g, ' ').trim();
|
|
4186
|
-
if (!text)
|
|
4187
|
-
return '';
|
|
4188
|
-
return text.length > 50 ? text.substring(0, 50) + '…' : text;
|
|
4189
|
-
}
|
|
4190
|
-
}
|
|
4191
|
-
/** 进程级 menu 操作(/agent、/system)鉴权:发送方 AID 必须在 owners 名单中。
|
|
4192
|
-
* owners 来自 evolclaw.json 顶层(进程级控制面配置)。纯静态名单比对。 */
|
|
4193
|
-
export function isProcessLevelOwner(peerId, owners) {
|
|
4194
|
-
if (!peerId)
|
|
4195
|
-
return false;
|
|
4196
|
-
return (owners ?? []).includes(peerId);
|
|
4197
|
-
}
|
|
4198
|
-
/** ECWeb menu.response 错误信封。 */
|
|
4199
|
-
function ecwebErr(id, name, code, message) {
|
|
4200
|
-
return { type: 'menu.response', id, ...(name ? { name } : {}), error: { code, message } };
|
|
4201
|
-
}
|
|
4202
|
-
/** 把 execMenu* 的 {data}|{error} 结果转成 menu.response。 */
|
|
4203
|
-
function ecwebResp(id, name, result) {
|
|
4204
|
-
return 'error' in result
|
|
4205
|
-
? { type: 'menu.response', id, ...(name ? { name } : {}), error: { code: result.code ?? 'EXEC_FAILED', message: result.error } }
|
|
4206
|
-
: { type: 'menu.response', id, ...(name ? { name } : {}), data: result.data };
|
|
4207
|
-
}
|
|
4208
|
-
/** 校验 menu 路径直传的 trigger 调度参数(绕过 parseTriggerSet 文本解析后必须自校验)。
|
|
4209
|
-
* 返回错误字符串表示非法;返回 null 表示通过。
|
|
4210
|
-
* 防止非法 scheduleType/scheduleValue 传到 calcNextFireAt 产出 NaN/throw,污染 scheduler heap。 */
|
|
4211
|
-
export function validateScheduleParams(scheduleType, scheduleValue) {
|
|
4212
|
-
if (!['delay', 'at', 'cron'].includes(scheduleType)) {
|
|
4213
|
-
return `无效 scheduleType: ${scheduleType}(可选: delay / at / cron)`;
|
|
4214
|
-
}
|
|
4215
|
-
if (scheduleType === 'delay') {
|
|
4216
|
-
const ms = Number(scheduleValue);
|
|
4217
|
-
if (!Number.isFinite(ms) || ms <= 0)
|
|
4218
|
-
return `delay 的 scheduleValue 需为正整数毫秒: ${scheduleValue}`;
|
|
4219
|
-
}
|
|
4220
|
-
else if (scheduleType === 'at') {
|
|
4221
|
-
const ts = new Date(scheduleValue).getTime();
|
|
4222
|
-
if (!Number.isFinite(ts))
|
|
4223
|
-
return `at 的 scheduleValue 需为合法时间: ${scheduleValue}`;
|
|
4224
|
-
}
|
|
4225
|
-
else {
|
|
4226
|
-
// cron:交给 calcNextFireAt 内部的 CronExpressionParser 校验(会 throw,被上层 catch)
|
|
4227
|
-
try {
|
|
4228
|
-
calcNextFireAt('cron', scheduleValue, Date.now());
|
|
4229
|
-
}
|
|
4230
|
-
catch {
|
|
4231
|
-
return `无效 cron 表达式: ${scheduleValue}`;
|
|
4232
|
-
}
|
|
4233
|
-
}
|
|
4234
|
-
return null;
|
|
4235
|
-
}
|