evolclaw 2.4.0 → 2.5.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/README.md +33 -14
- package/dist/agents/claude-runner.js +224 -23
- package/dist/agents/codex-runner.js +2 -8
- package/dist/agents/gemini-runner.js +1 -8
- package/dist/channels/aun.js +438 -53
- package/dist/channels/dingtalk.js +506 -0
- package/dist/channels/feishu.js +31 -231
- package/dist/channels/qqbot.js +391 -0
- package/dist/channels/wechat.js +36 -38
- package/dist/channels/wecom.js +549 -0
- package/dist/cli.js +69 -9
- package/dist/config.js +98 -2
- package/dist/core/command-handler.js +462 -112
- package/dist/core/message/message-bridge.js +8 -5
- package/dist/core/message/message-processor.js +146 -54
- package/dist/core/message/message-queue.js +48 -0
- package/dist/core/message/stream-flusher.js +2 -2
- package/dist/core/session/session-manager.js +21 -3
- package/dist/index.js +48 -13
- package/dist/ipc.js +14 -4
- package/dist/templates/skills.md +64 -0
- package/dist/utils/error-dict.js +63 -0
- package/dist/utils/error-utils.js +156 -56
- package/dist/utils/format.js +32 -0
- package/dist/utils/init-channel.js +734 -8
- package/dist/utils/init.js +33 -2
- package/dist/utils/media-cache.js +2 -0
- package/dist/utils/stats-collector.js +0 -8
- package/package.json +9 -3
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ClaudeSessionFileAdapter } from './core/session/adapters/claude-session-file-adapter.js';
|
|
2
2
|
import { CodexSessionFileAdapter } from './core/session/adapters/codex-session-file-adapter.js';
|
|
3
3
|
import { GeminiSessionFileAdapter } from './core/session/adapters/gemini-session-file-adapter.js';
|
|
4
|
-
import { loadConfig, ensureDataDirs, resolvePaths, resolveAnthropicConfig, isOwner, validateConfigIntegrity, validateChannelInstanceNames, getOwner } from './config.js';
|
|
4
|
+
import { loadConfig, ensureDataDirs, resolvePaths, resolveAnthropicConfig, isOwner, isAdmin, validateConfigIntegrity, validateChannelInstanceNames, getOwner } from './config.js';
|
|
5
5
|
import { SessionManager } from './core/session/session-manager.js';
|
|
6
6
|
import { ClaudeAgentPlugin } from './agents/claude-runner.js';
|
|
7
7
|
import { CodexAgentPlugin } from './agents/codex-runner.js';
|
|
@@ -9,6 +9,9 @@ import { GeminiAgentPlugin } from './agents/gemini-runner.js';
|
|
|
9
9
|
import { FeishuChannelPlugin } from './channels/feishu.js';
|
|
10
10
|
import { WechatChannelPlugin } from './channels/wechat.js';
|
|
11
11
|
import { AUNChannelPlugin } from './channels/aun.js';
|
|
12
|
+
import { DingtalkChannelPlugin } from './channels/dingtalk.js';
|
|
13
|
+
import { QQBotChannelPlugin } from './channels/qqbot.js';
|
|
14
|
+
import { WecomChannelPlugin } from './channels/wecom.js';
|
|
12
15
|
import { MessageProcessor } from './core/message/message-processor.js';
|
|
13
16
|
import { MessageQueue } from './core/message/message-queue.js';
|
|
14
17
|
import { MessageBridge } from './core/message/message-bridge.js';
|
|
@@ -47,6 +50,7 @@ async function main() {
|
|
|
47
50
|
ensureDataDirs();
|
|
48
51
|
// 加载配置
|
|
49
52
|
const config = loadConfig();
|
|
53
|
+
const paths = resolvePaths();
|
|
50
54
|
// 配置完整性校验
|
|
51
55
|
const integrity = validateConfigIntegrity(config);
|
|
52
56
|
if (!integrity.valid) {
|
|
@@ -68,9 +72,7 @@ async function main() {
|
|
|
68
72
|
// 统计收集器(近 1 小时滚动统计)
|
|
69
73
|
const statsCollector = new StatsCollector(eventBus);
|
|
70
74
|
// 初始化数据库(带 ownerResolver)
|
|
71
|
-
const sessionManager = new SessionManager(undefined, eventBus, (channel, userId) =>
|
|
72
|
-
return isOwner(config, channel, userId);
|
|
73
|
-
});
|
|
75
|
+
const sessionManager = new SessionManager(undefined, eventBus, (channel, userId) => isOwner(config, channel, userId), (channel, userId) => isAdmin(config, channel, userId));
|
|
74
76
|
logger.info('✓ Database initialized');
|
|
75
77
|
// 注册会话文件适配器(Claude / Codex 各自的会话文件操作)
|
|
76
78
|
sessionManager.registerFileAdapter(new ClaudeSessionFileAdapter());
|
|
@@ -118,6 +120,9 @@ async function main() {
|
|
|
118
120
|
channelLoader.register(new FeishuChannelPlugin());
|
|
119
121
|
channelLoader.register(new WechatChannelPlugin());
|
|
120
122
|
channelLoader.register(new AUNChannelPlugin());
|
|
123
|
+
channelLoader.register(new DingtalkChannelPlugin());
|
|
124
|
+
channelLoader.register(new QQBotChannelPlugin());
|
|
125
|
+
channelLoader.register(new WecomChannelPlugin());
|
|
121
126
|
const channelInstances = await channelLoader.createAll(config);
|
|
122
127
|
logger.info(`✓ Created ${channelInstances.length} channel instance(s)`);
|
|
123
128
|
// 启动迁移:将 sessions.channel 从 channelType 回填为实例名
|
|
@@ -163,13 +168,14 @@ async function main() {
|
|
|
163
168
|
messageQueue.setEventBus(eventBus);
|
|
164
169
|
// 回填 messageQueue 引用
|
|
165
170
|
cmdHandler.setMessageQueue(messageQueue);
|
|
171
|
+
processor.setMessageQueue(messageQueue);
|
|
166
172
|
// 默认策略
|
|
167
173
|
const defaultPolicy = {
|
|
168
|
-
canSwitchProject: (chatType, role) => chatType === 'private' || role === 'owner',
|
|
169
|
-
canListProjects: (chatType, role) => chatType === 'private' || role === 'owner',
|
|
174
|
+
canSwitchProject: (chatType, role) => chatType === 'private' ? (role === 'owner' || role === 'admin') : role === 'owner',
|
|
175
|
+
canListProjects: (chatType, role) => chatType === 'private' ? (role === 'owner' || role === 'admin') : role === 'owner',
|
|
170
176
|
canCreateSession: () => true,
|
|
171
|
-
canDeleteSession: (chatType, role) => chatType === 'private' || role === 'owner',
|
|
172
|
-
canImportCliSession: (chatType, role) => chatType === 'private' || role === 'owner',
|
|
177
|
+
canDeleteSession: (chatType, role) => chatType === 'private' ? (role === 'owner' || role === 'admin') : role === 'owner',
|
|
178
|
+
canImportCliSession: (chatType, role) => chatType === 'private' ? (role === 'owner' || role === 'admin') : role === 'owner',
|
|
173
179
|
messagePrefix: () => '',
|
|
174
180
|
showMiddleResult: () => true,
|
|
175
181
|
showIdleMonitor: () => true,
|
|
@@ -220,9 +226,6 @@ async function main() {
|
|
|
220
226
|
replyToMessageId: replyContext?.replyToMessageId,
|
|
221
227
|
replyInThread: replyContext?.replyInThread,
|
|
222
228
|
}), inst.adapter, channelType);
|
|
223
|
-
inst.channel.onRecall?.((messageId) => {
|
|
224
|
-
msgBridge.cancel(messageId);
|
|
225
|
-
});
|
|
226
229
|
}
|
|
227
230
|
if (channelType === 'wechat') {
|
|
228
231
|
// 注入 EventBus(用于 channel:health 事件)
|
|
@@ -256,6 +259,38 @@ async function main() {
|
|
|
256
259
|
});
|
|
257
260
|
}), (channelId, text, replyContext) => inst.channel.sendMessage(channelId, text, replyContext), inst.adapter, channelType);
|
|
258
261
|
}
|
|
262
|
+
if (channelType === 'dingtalk') {
|
|
263
|
+
msgBridge.register(inst.adapter.channelName, (handler) => inst.channel.onMessage(async (event) => {
|
|
264
|
+
handler({
|
|
265
|
+
channel: channelType,
|
|
266
|
+
channelId: event.channelId,
|
|
267
|
+
content: event.content,
|
|
268
|
+
images: event.images,
|
|
269
|
+
chatType: event.chatType || 'private',
|
|
270
|
+
peerId: event.peerId || '',
|
|
271
|
+
peerName: event.peerName,
|
|
272
|
+
messageId: event.messageId,
|
|
273
|
+
});
|
|
274
|
+
}), (channelId, text) => inst.channel.sendMessage(channelId, text), inst.adapter, channelType);
|
|
275
|
+
}
|
|
276
|
+
if (channelType === 'qqbot') {
|
|
277
|
+
msgBridge.register(inst.adapter.channelName, (handler) => inst.channel.onMessage(async (event) => {
|
|
278
|
+
handler({
|
|
279
|
+
channel: channelType,
|
|
280
|
+
channelId: event.channelId,
|
|
281
|
+
content: event.content,
|
|
282
|
+
images: event.images,
|
|
283
|
+
chatType: event.chatType || 'private',
|
|
284
|
+
peerId: event.peerId || '',
|
|
285
|
+
peerName: event.peerName,
|
|
286
|
+
messageId: event.messageId,
|
|
287
|
+
});
|
|
288
|
+
}), (channelId, text) => inst.channel.sendMessage(channelId, text), inst.adapter, channelType);
|
|
289
|
+
}
|
|
290
|
+
// 通用:撤回消息 → 中断执行中任务(所有支持 onRecall 的渠道)
|
|
291
|
+
inst.channel.onRecall?.((messageId) => {
|
|
292
|
+
msgBridge.cancel(messageId);
|
|
293
|
+
});
|
|
259
294
|
}
|
|
260
295
|
// ── 连接所有渠道 ──
|
|
261
296
|
const connected = await channelLoader.connectAll(channelInstances);
|
|
@@ -394,7 +429,7 @@ async function main() {
|
|
|
394
429
|
const readySignalPath = resolvePaths().readySignal;
|
|
395
430
|
fs.writeFileSync(readySignalPath, String(Date.now()));
|
|
396
431
|
logger.info(`✓ Ready signal written: ${readySignalPath}`);
|
|
397
|
-
// IPC server — 供 CLI 查询实时状态
|
|
432
|
+
// IPC server — 供 CLI 查询实时状态 + Agent ctl 指令执行
|
|
398
433
|
const ipcServer = new IpcServer(resolvePaths().socket, () => {
|
|
399
434
|
const channels = {};
|
|
400
435
|
const channelsByType = {};
|
|
@@ -424,7 +459,7 @@ async function main() {
|
|
|
424
459
|
avgResponseMs: snap.lastHour.avgResponseMs,
|
|
425
460
|
},
|
|
426
461
|
};
|
|
427
|
-
});
|
|
462
|
+
}, async (cmd, sessionId) => cmdHandler.handleCtl(cmd, sessionId));
|
|
428
463
|
ipcServer.start();
|
|
429
464
|
// 运行时配置文件监控
|
|
430
465
|
const configPath = resolvePaths().config;
|
package/dist/ipc.js
CHANGED
|
@@ -4,10 +4,12 @@ import { logger } from './utils/logger.js';
|
|
|
4
4
|
export class IpcServer {
|
|
5
5
|
socketPath;
|
|
6
6
|
getStatus;
|
|
7
|
+
commandExecutor;
|
|
7
8
|
server = null;
|
|
8
|
-
constructor(socketPath, getStatus) {
|
|
9
|
+
constructor(socketPath, getStatus, commandExecutor) {
|
|
9
10
|
this.socketPath = socketPath;
|
|
10
11
|
this.getStatus = getStatus;
|
|
12
|
+
this.commandExecutor = commandExecutor;
|
|
11
13
|
}
|
|
12
14
|
start() {
|
|
13
15
|
// Remove stale socket file
|
|
@@ -17,7 +19,7 @@ export class IpcServer {
|
|
|
17
19
|
catch { }
|
|
18
20
|
this.server = net.createServer((conn) => {
|
|
19
21
|
let buf = '';
|
|
20
|
-
conn.on('data', (data) => {
|
|
22
|
+
conn.on('data', async (data) => {
|
|
21
23
|
buf += data.toString();
|
|
22
24
|
// Simple newline-delimited JSON protocol
|
|
23
25
|
const idx = buf.indexOf('\n');
|
|
@@ -27,7 +29,7 @@ export class IpcServer {
|
|
|
27
29
|
buf = buf.slice(idx + 1);
|
|
28
30
|
try {
|
|
29
31
|
const cmd = JSON.parse(line);
|
|
30
|
-
const response = this.handleCommand(cmd);
|
|
32
|
+
const response = await this.handleCommand(cmd);
|
|
31
33
|
conn.end(JSON.stringify(response) + '\n');
|
|
32
34
|
}
|
|
33
35
|
catch {
|
|
@@ -58,12 +60,20 @@ export class IpcServer {
|
|
|
58
60
|
}
|
|
59
61
|
catch { }
|
|
60
62
|
}
|
|
61
|
-
handleCommand(cmd) {
|
|
63
|
+
async handleCommand(cmd) {
|
|
62
64
|
switch (cmd.type) {
|
|
63
65
|
case 'status':
|
|
64
66
|
return this.getStatus();
|
|
65
67
|
case 'ping':
|
|
66
68
|
return { pong: true, pid: process.pid };
|
|
69
|
+
case 'ctl': {
|
|
70
|
+
if (!this.commandExecutor)
|
|
71
|
+
return { ok: false, error: 'ctl not configured' };
|
|
72
|
+
const { cmd: slashCmd, sessionId } = cmd;
|
|
73
|
+
if (!slashCmd || !sessionId)
|
|
74
|
+
return { ok: false, error: 'missing cmd or sessionId' };
|
|
75
|
+
return await this.commandExecutor(slashCmd, sessionId);
|
|
76
|
+
}
|
|
67
77
|
default:
|
|
68
78
|
return { error: `unknown command: ${cmd.type}` };
|
|
69
79
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: evolclaw-ctl
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: EvolClaw 运行时自管理指令,仅在 evolclaw 托管环境中可用
|
|
5
|
+
trigger: 用户询问或需要切换模型、调整推理强度、查看运行状态、压缩上下文、检查通道健康、管理权限模式、发送文件、重启服务、重连渠道时
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# EvolClaw Ctl
|
|
9
|
+
|
|
10
|
+
通过 `evolclaw ctl <command> [args]` 管理运行时配置。仅在 evolclaw 托管环境中可用(`EVOLCLAW_SESSION_ID` 已设置)。
|
|
11
|
+
|
|
12
|
+
## 可用指令
|
|
13
|
+
|
|
14
|
+
### 查询类(所有用户)
|
|
15
|
+
- `evolclaw ctl help` — 显示帮助
|
|
16
|
+
- `evolclaw ctl status` — 显示会话状态
|
|
17
|
+
- `evolclaw ctl check` — 检查渠道健康状态
|
|
18
|
+
|
|
19
|
+
### 配置类(管理员)
|
|
20
|
+
- `evolclaw ctl model` — 查看当前模型和可选列表
|
|
21
|
+
- `evolclaw ctl model <model-id>` — 切换模型(如 `opus`, `sonnet`, `haiku`)
|
|
22
|
+
- `evolclaw ctl effort` — 查看当前推理强度
|
|
23
|
+
- `evolclaw ctl effort <low|medium|high|max>` — 切换推理强度
|
|
24
|
+
- `evolclaw ctl compact` — 压缩当前会话上下文
|
|
25
|
+
|
|
26
|
+
### 权限类
|
|
27
|
+
- `evolclaw ctl perm` — 查看当前权限模式(管理员)
|
|
28
|
+
- `evolclaw ctl perm <mode>` — 切换权限模式(仅 owner)
|
|
29
|
+
|
|
30
|
+
### 运维类(仅 owner)
|
|
31
|
+
- `evolclaw ctl activity <all|dm|owner|none>` — 查看/控制中间输出显示模式
|
|
32
|
+
- `evolclaw ctl send [channel] <path>` — 发送项目内文件(仅限项目目录内)
|
|
33
|
+
- `evolclaw ctl restart` — 重启服务(慎用:中断所有会话)
|
|
34
|
+
- `evolclaw ctl restart <channel>` — 重连指定渠道(管理员可用)
|
|
35
|
+
- `evolclaw ctl agentmd` — 查看当前 agent.md
|
|
36
|
+
- `evolclaw ctl agentmd put` — 发布本地 agent.md
|
|
37
|
+
- `evolclaw ctl agentmd set <内容>` — 直接设置 agent.md 内容
|
|
38
|
+
|
|
39
|
+
## 使用示例
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# 查看当前模型
|
|
43
|
+
evolclaw ctl model
|
|
44
|
+
|
|
45
|
+
# 切换到 opus
|
|
46
|
+
evolclaw ctl model opus
|
|
47
|
+
|
|
48
|
+
# 降低推理强度以加快响应
|
|
49
|
+
evolclaw ctl effort low
|
|
50
|
+
|
|
51
|
+
# 压缩上下文
|
|
52
|
+
evolclaw ctl compact
|
|
53
|
+
|
|
54
|
+
# 查看服务状态
|
|
55
|
+
evolclaw ctl status
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 注意事项
|
|
59
|
+
|
|
60
|
+
- 仅在 evolclaw 托管环境中可用(EVOLCLAW_SESSION_ID 环境变量已设置时)
|
|
61
|
+
- 权限继承当前会话用户的角色(owner / admin / guest)
|
|
62
|
+
- `compact` 不能在当前会话处理消息期间执行
|
|
63
|
+
- `send` 只能发送项目目录下的文件
|
|
64
|
+
- `restart` 会中断当前所有会话,谨慎使用
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
let rules = [];
|
|
4
|
+
/**
|
|
5
|
+
* 加载错误字典文件
|
|
6
|
+
* 文件不存在或格式错误 → 警告日志,rules = [](不影响启动)
|
|
7
|
+
*/
|
|
8
|
+
export function loadErrorDict(dictPath) {
|
|
9
|
+
try {
|
|
10
|
+
if (!fs.existsSync(dictPath)) {
|
|
11
|
+
logger.debug('[error-dict] 字典文件不存在,使用内置规则: %s', dictPath);
|
|
12
|
+
rules = [];
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const raw = JSON.parse(fs.readFileSync(dictPath, 'utf-8'));
|
|
16
|
+
if (!Array.isArray(raw?.rules)) {
|
|
17
|
+
logger.warn('[error-dict] 字典格式错误(缺少 rules 数组),忽略: %s', dictPath);
|
|
18
|
+
rules = [];
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
// 校验每条规则的必填字段
|
|
22
|
+
rules = raw.rules.filter((r) => {
|
|
23
|
+
if (!r.id || !r.match || !r.action) {
|
|
24
|
+
logger.warn('[error-dict] 跳过无效规则(缺少 id/match/action): %o', r);
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (!['retry', 'stop', 'ignore'].includes(r.action)) {
|
|
28
|
+
logger.warn('[error-dict] 跳过无效 action "%s": %s', r.action, r.id);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
});
|
|
33
|
+
logger.info('[error-dict] 已加载 %d 条规则: %s', rules.length, dictPath);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
logger.warn('[error-dict] 加载失败: %s — %s', dictPath, err.message);
|
|
37
|
+
rules = [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** 运行时重新加载(供 /reload 命令使用) */
|
|
41
|
+
export function reloadErrorDict(dictPath) {
|
|
42
|
+
loadErrorDict(dictPath);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 匹配错误消息,返回首条命中的规则
|
|
46
|
+
* @param errorMessage 已 toLowerCase 的错误消息
|
|
47
|
+
*/
|
|
48
|
+
export function matchErrorRule(errorMessage) {
|
|
49
|
+
for (const rule of rules) {
|
|
50
|
+
if (errorMessage.includes(rule.match.toLowerCase())) {
|
|
51
|
+
return rule;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/** 获取当前已加载的规则数量(供测试和状态查询使用) */
|
|
57
|
+
export function getLoadedRuleCount() {
|
|
58
|
+
return rules.length;
|
|
59
|
+
}
|
|
60
|
+
/** 重置规则(仅供测试使用) */
|
|
61
|
+
export function _resetRules() {
|
|
62
|
+
rules = [];
|
|
63
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { resolvePaths } from '../paths.js';
|
|
4
|
+
import { logger } from './logger.js';
|
|
1
5
|
export var ErrorType;
|
|
2
6
|
(function (ErrorType) {
|
|
3
7
|
ErrorType["SDK_TIMEOUT"] = "sdk_timeout";
|
|
@@ -62,32 +66,152 @@ export function isInfraError(subtype, terminalReason) {
|
|
|
62
66
|
export function prefixErrorType(prefix, errorType) {
|
|
63
67
|
return `${prefix}:${errorType}`;
|
|
64
68
|
}
|
|
69
|
+
// ── 错误字典 ──────────────────────────────────────────────────────────
|
|
70
|
+
const VALID_ACTIONS = new Set(['retry', 'stop', 'ignore']);
|
|
71
|
+
let _dictPath = null;
|
|
72
|
+
let _rules = [];
|
|
73
|
+
let _lastMtime = 0;
|
|
74
|
+
function getDictPath() {
|
|
75
|
+
if (!_dictPath) {
|
|
76
|
+
_dictPath = path.join(resolvePaths().dataDir, 'error-dict.json');
|
|
77
|
+
}
|
|
78
|
+
return _dictPath;
|
|
79
|
+
}
|
|
80
|
+
/** 校验单条规则,返回错误原因(null = 合法) */
|
|
81
|
+
function validateRule(r, index) {
|
|
82
|
+
if (!r || typeof r !== 'object')
|
|
83
|
+
return `rules[${index}]: 不是对象`;
|
|
84
|
+
if (!r.id || typeof r.id !== 'string')
|
|
85
|
+
return `rules[${index}]: 缺少 id 或类型不是 string`;
|
|
86
|
+
if (!r.match || typeof r.match !== 'string')
|
|
87
|
+
return `rules[${index}] (${r.id}): 缺少 match 或类型不是 string`;
|
|
88
|
+
if (!VALID_ACTIONS.has(r.action))
|
|
89
|
+
return `rules[${index}] (${r.id}): action 无效 "${r.action}",允许值: retry/stop/ignore`;
|
|
90
|
+
if (r.type !== undefined && typeof r.type !== 'string')
|
|
91
|
+
return `rules[${index}] (${r.id}): type 类型不是 string`;
|
|
92
|
+
if (r.message !== undefined && typeof r.message !== 'string')
|
|
93
|
+
return `rules[${index}] (${r.id}): message 类型不是 string`;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 刷新字典:检查文件 mtime,有变化则重新读取并校验。
|
|
98
|
+
* 校验不通过 → 不更新内存数据,记录告警日志。
|
|
99
|
+
*/
|
|
100
|
+
function refreshDict() {
|
|
101
|
+
const dictPath = getDictPath();
|
|
102
|
+
let stat;
|
|
103
|
+
try {
|
|
104
|
+
stat = fs.statSync(dictPath);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// 文件不存在 → 清空规则(首次)或保持不变(之前有数据且文件被删)
|
|
108
|
+
if (_rules.length > 0) {
|
|
109
|
+
logger.warn('[error-dict] 字典文件已删除,清空规则: %s', dictPath);
|
|
110
|
+
_rules = [];
|
|
111
|
+
_lastMtime = 0;
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const mtime = stat.mtimeMs;
|
|
116
|
+
if (mtime === _lastMtime)
|
|
117
|
+
return; // 文件未变化,跳过
|
|
118
|
+
// 文件有变化,尝试加载
|
|
119
|
+
try {
|
|
120
|
+
const content = fs.readFileSync(dictPath, 'utf-8');
|
|
121
|
+
let raw;
|
|
122
|
+
try {
|
|
123
|
+
raw = JSON.parse(content);
|
|
124
|
+
}
|
|
125
|
+
catch (parseErr) {
|
|
126
|
+
logger.warn('[error-dict] JSON 解析失败,保留原有规则: %s — %s', dictPath, parseErr.message);
|
|
127
|
+
_lastMtime = mtime; // 标记已检查,避免重复读取
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.rules)) {
|
|
131
|
+
logger.warn('[error-dict] 字典格式错误(缺少 rules 数组),保留原有规则: %s', dictPath);
|
|
132
|
+
_lastMtime = mtime;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// 逐条校验
|
|
136
|
+
const errors = [];
|
|
137
|
+
for (let i = 0; i < raw.rules.length; i++) {
|
|
138
|
+
const err = validateRule(raw.rules[i], i);
|
|
139
|
+
if (err)
|
|
140
|
+
errors.push(err);
|
|
141
|
+
}
|
|
142
|
+
if (errors.length > 0) {
|
|
143
|
+
logger.warn('[error-dict] 字典校验失败(%d 条错误),保留原有规则:\n %s', errors.length, errors.join('\n '));
|
|
144
|
+
_lastMtime = mtime;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// 全部通过,更新
|
|
148
|
+
_rules = raw.rules;
|
|
149
|
+
_lastMtime = mtime;
|
|
150
|
+
logger.info('[error-dict] 已加载 %d 条规则: %s', _rules.length, dictPath);
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
logger.warn('[error-dict] 读取失败,保留原有规则: %s — %s', dictPath, err.message);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* 匹配错误消息,返回首条命中的规则。
|
|
158
|
+
* 每次调用自动检查文件变化(基于 mtime,无变化零开销)。
|
|
159
|
+
* @param errorMessage 已 toLowerCase 的错误消息
|
|
160
|
+
*/
|
|
161
|
+
export function matchErrorRule(errorMessage) {
|
|
162
|
+
refreshDict();
|
|
163
|
+
for (const rule of _rules) {
|
|
164
|
+
if (errorMessage.includes(rule.match.toLowerCase())) {
|
|
165
|
+
return rule;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
/** 获取当前已加载的规则数量(供测试和状态查询使用) */
|
|
171
|
+
export function getLoadedRuleCount() {
|
|
172
|
+
refreshDict();
|
|
173
|
+
return _rules.length;
|
|
174
|
+
}
|
|
175
|
+
/** 重置字典状态(仅供测试使用) */
|
|
176
|
+
export function _resetDict() {
|
|
177
|
+
_rules = [];
|
|
178
|
+
_lastMtime = 0;
|
|
179
|
+
_dictPath = null;
|
|
180
|
+
}
|
|
181
|
+
/** 设置字典文件路径(仅供测试使用) */
|
|
182
|
+
export function _setDictPath(p) {
|
|
183
|
+
_dictPath = p;
|
|
184
|
+
_lastMtime = 0; // 强制下次刷新
|
|
185
|
+
}
|
|
186
|
+
// ── 错误分类 / 重试 / 消息 ──────────────────────────────────────────
|
|
65
187
|
export function classifyError(error) {
|
|
66
188
|
const msg = (error?.message || '').toLowerCase();
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
189
|
+
// 字典优先 — 命中则直接返回
|
|
190
|
+
const rule = matchErrorRule(msg);
|
|
191
|
+
if (rule) {
|
|
192
|
+
if (rule.type)
|
|
193
|
+
return rule.type;
|
|
194
|
+
if (rule.action === 'retry')
|
|
195
|
+
return ErrorType.API_ERROR;
|
|
196
|
+
if (rule.action === 'stop')
|
|
197
|
+
return ErrorType.AUTH_ERROR;
|
|
198
|
+
return ErrorType.UNKNOWN;
|
|
199
|
+
}
|
|
200
|
+
// 内置兜底规则(结构性、稳定的错误模式)
|
|
201
|
+
if (msg.includes('context_length_exceeded') || msg.includes('context_compact_failed')
|
|
202
|
+
|| msg.includes('context limit')) {
|
|
70
203
|
return ErrorType.CONTEXT_TOO_LONG;
|
|
71
204
|
}
|
|
72
|
-
|
|
73
|
-
if (msg.includes('401') || msg.includes('invalid api key') || msg.includes('key_not_found')
|
|
74
|
-
|| msg.includes('authentication_error') || msg.includes('failed to authenticate')) {
|
|
205
|
+
if (msg.includes('401') || msg.includes('authentication_error')) {
|
|
75
206
|
return ErrorType.AUTH_ERROR;
|
|
76
207
|
}
|
|
77
208
|
if (msg.includes('timeout') || msg.includes('etimedout')) {
|
|
78
209
|
return ErrorType.SDK_TIMEOUT;
|
|
79
210
|
}
|
|
80
|
-
if (msg.includes('
|
|
81
|
-
return ErrorType.API_ERROR;
|
|
82
|
-
}
|
|
83
|
-
// "X is not valid JSON" — API 返回了非 JSON 响应(如算力池切换提示),属于 API 错误
|
|
84
|
-
if (msg.includes('is not valid json')) {
|
|
85
|
-
return ErrorType.API_ERROR;
|
|
86
|
-
}
|
|
87
|
-
if (msg.includes('enoent') || msg.includes('corrupt') || msg.includes('invalid json')) {
|
|
211
|
+
if (msg.includes('enoent') || msg.includes('corrupt')) {
|
|
88
212
|
return ErrorType.FILE_CORRUPT;
|
|
89
213
|
}
|
|
90
|
-
if (msg.includes('
|
|
214
|
+
if (msg.includes('aborted') || msg.includes('interrupted')) {
|
|
91
215
|
return ErrorType.STREAM_ERROR;
|
|
92
216
|
}
|
|
93
217
|
return ErrorType.UNKNOWN;
|
|
@@ -100,26 +224,16 @@ export function classifyError(error) {
|
|
|
100
224
|
export function isRetryableError(error) {
|
|
101
225
|
const msg = error?.message || String(error);
|
|
102
226
|
const lower = msg.toLowerCase();
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return false;
|
|
227
|
+
// 字典优先 — 命中则直接返回
|
|
228
|
+
const rule = matchErrorRule(lower);
|
|
229
|
+
if (rule)
|
|
230
|
+
return rule.action === 'retry';
|
|
231
|
+
// 内置兜底规则(结构性错误码)
|
|
232
|
+
if (lower.includes('401') || lower.includes('authentication_error')) {
|
|
233
|
+
return false; // 认证错误不可重试
|
|
111
234
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (msg.includes('API Error: 429'))
|
|
115
|
-
return true;
|
|
116
|
-
if (msg.includes('API Error: 500'))
|
|
117
|
-
return true;
|
|
118
|
-
if (msg.includes('API Error: 502'))
|
|
119
|
-
return true;
|
|
120
|
-
if (msg.includes('API Error: 503'))
|
|
121
|
-
return true;
|
|
122
|
-
if (msg.includes('API Error: 504'))
|
|
235
|
+
// HTTP 5xx / 429 — 标准可重试状态码
|
|
236
|
+
if (/api error: (429|5\d{2})\b/.test(lower))
|
|
123
237
|
return true;
|
|
124
238
|
return false;
|
|
125
239
|
}
|
|
@@ -148,34 +262,20 @@ export function getErrorMessage(error, terminalReason) {
|
|
|
148
262
|
}
|
|
149
263
|
// 回退到原有的错误消息匹配逻辑
|
|
150
264
|
const msg = error?.message || String(error);
|
|
151
|
-
|
|
265
|
+
// 字典优先 — 命中且有自定义消息则直接返回
|
|
266
|
+
const rule = matchErrorRule(msg.toLowerCase());
|
|
267
|
+
if (rule?.message)
|
|
268
|
+
return rule.message;
|
|
269
|
+
// 内置兜底规则(结构性错误)
|
|
270
|
+
if (msg.includes('CONTEXT_COMPACT_FAILED') || msg.includes('context_length_exceeded')
|
|
271
|
+
|| msg.includes('Context limit')) {
|
|
152
272
|
return '⚠️ 上下文过长,自动压缩失败,请手动输入 /compact 重试';
|
|
153
273
|
}
|
|
154
|
-
if (msg.includes('
|
|
155
|
-
|| msg.includes('Prompt is too long') || msg.includes('Context limit')) {
|
|
156
|
-
return '⚠️ 上下文过长,自动压缩重试失败,请手动输入 /compact 重试';
|
|
157
|
-
}
|
|
158
|
-
if (msg.includes('API Error: 400')) {
|
|
159
|
-
return '❌ 请求格式错误,请检查输入内容';
|
|
160
|
-
}
|
|
161
|
-
if (msg.includes('401') || msg.includes('Invalid API key') || msg.includes('key_not_found')
|
|
162
|
-
|| msg.includes('authentication_error')) {
|
|
274
|
+
if (msg.includes('401') || msg.includes('authentication_error')) {
|
|
163
275
|
return '❌ API Key 无效,请检查密钥配置。使用 /status 查看当前配置';
|
|
164
276
|
}
|
|
165
|
-
if (msg.includes('API Error: 500')) {
|
|
166
|
-
return '❌ API 服务暂时不可用,请稍后重试';
|
|
167
|
-
}
|
|
168
|
-
if (msg.includes('API Error: 403')) {
|
|
169
|
-
return '❌ API 认证失败,请检查密钥配置或稍后重试';
|
|
170
|
-
}
|
|
171
|
-
if (msg.includes('API Error: 429')) {
|
|
172
|
-
return '⚠️ 请求过于频繁,请稍后再试';
|
|
173
|
-
}
|
|
174
277
|
if (msg.includes('timeout')) {
|
|
175
278
|
return '⚠️ 请求超时,请重试';
|
|
176
279
|
}
|
|
177
|
-
if (msg.includes('permission') || msg.includes('im:resource')) {
|
|
178
|
-
return '❌ 权限不足,请联系管理员配置应用权限';
|
|
179
|
-
}
|
|
180
280
|
return '❌ 处理消息时出错,请稍后重试';
|
|
181
281
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ── Markdown → Plain Text ───────────────────────────────────────────────────
|
|
2
|
+
export function markdownToPlainText(text) {
|
|
3
|
+
let result = text;
|
|
4
|
+
// Code blocks: strip fences, keep content
|
|
5
|
+
result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code) => code.trim());
|
|
6
|
+
// Images: remove entirely
|
|
7
|
+
result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
|
|
8
|
+
// Links: keep display text only
|
|
9
|
+
result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
|
|
10
|
+
// Tables: remove separator rows
|
|
11
|
+
result = result.replace(/^\|[\s:|-]+\|$/gm, '');
|
|
12
|
+
result = result.replace(/^\|(.+)\|$/gm, (_, inner) => inner.split('|').map(cell => cell.trim()).join(' '));
|
|
13
|
+
// Bold/italic
|
|
14
|
+
result = result.replace(/\*\*(.+?)\*\*/g, '$1');
|
|
15
|
+
result = result.replace(/\*(.+?)\*/g, '$1');
|
|
16
|
+
result = result.replace(/__(.+?)__/g, '$1');
|
|
17
|
+
result = result.replace(/_(.+?)_/g, '$1');
|
|
18
|
+
// Strikethrough
|
|
19
|
+
result = result.replace(/~~(.+?)~~/g, '$1');
|
|
20
|
+
// Inline code
|
|
21
|
+
result = result.replace(/`([^`]+)`/g, '$1');
|
|
22
|
+
// Headers
|
|
23
|
+
result = result.replace(/^#{1,6}\s+/gm, '');
|
|
24
|
+
// Blockquotes
|
|
25
|
+
result = result.replace(/^>\s?/gm, '');
|
|
26
|
+
// Horizontal rules
|
|
27
|
+
result = result.replace(/^[-*_]{3,}$/gm, '');
|
|
28
|
+
// List markers
|
|
29
|
+
result = result.replace(/^(\s*)[-*+]\s/gm, '$1');
|
|
30
|
+
result = result.replace(/^(\s*)\d+\.\s/gm, '$1');
|
|
31
|
+
return result.trim();
|
|
32
|
+
}
|