@adversity/coding-tool-x 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +333 -0
  2. package/LICENSE +21 -0
  3. package/README.md +404 -0
  4. package/bin/ctx.js +8 -0
  5. package/dist/web/assets/index-D1AYlFLZ.js +3220 -0
  6. package/dist/web/assets/index-aL3cKxSK.css +41 -0
  7. package/dist/web/favicon.ico +0 -0
  8. package/dist/web/index.html +14 -0
  9. package/dist/web/logo.png +0 -0
  10. package/docs/CHANGELOG.md +582 -0
  11. package/docs/DIRECTORY_MIGRATION.md +112 -0
  12. package/docs/PROJECT_STRUCTURE.md +396 -0
  13. package/docs/bannel.png +0 -0
  14. package/docs/home.png +0 -0
  15. package/docs/logo.png +0 -0
  16. package/docs/multi-channel-load-balancing.md +249 -0
  17. package/package.json +73 -0
  18. package/src/commands/channels.js +504 -0
  19. package/src/commands/cli-type.js +99 -0
  20. package/src/commands/daemon.js +286 -0
  21. package/src/commands/doctor.js +332 -0
  22. package/src/commands/list.js +222 -0
  23. package/src/commands/logs.js +259 -0
  24. package/src/commands/port-config.js +115 -0
  25. package/src/commands/proxy-control.js +258 -0
  26. package/src/commands/proxy.js +152 -0
  27. package/src/commands/resume.js +137 -0
  28. package/src/commands/search.js +190 -0
  29. package/src/commands/stats.js +224 -0
  30. package/src/commands/switch.js +48 -0
  31. package/src/commands/toggle-proxy.js +222 -0
  32. package/src/commands/ui.js +92 -0
  33. package/src/commands/workspace.js +454 -0
  34. package/src/config/default.js +40 -0
  35. package/src/config/loader.js +75 -0
  36. package/src/config/paths.js +121 -0
  37. package/src/index.js +373 -0
  38. package/src/reset-config.js +92 -0
  39. package/src/server/api/agents.js +248 -0
  40. package/src/server/api/aliases.js +36 -0
  41. package/src/server/api/channels.js +258 -0
  42. package/src/server/api/claude-hooks.js +480 -0
  43. package/src/server/api/codex-channels.js +312 -0
  44. package/src/server/api/codex-projects.js +91 -0
  45. package/src/server/api/codex-proxy.js +182 -0
  46. package/src/server/api/codex-sessions.js +491 -0
  47. package/src/server/api/codex-statistics.js +57 -0
  48. package/src/server/api/commands.js +245 -0
  49. package/src/server/api/config-templates.js +182 -0
  50. package/src/server/api/config.js +147 -0
  51. package/src/server/api/convert.js +127 -0
  52. package/src/server/api/dashboard.js +125 -0
  53. package/src/server/api/env.js +144 -0
  54. package/src/server/api/favorites.js +77 -0
  55. package/src/server/api/gemini-channels.js +261 -0
  56. package/src/server/api/gemini-projects.js +91 -0
  57. package/src/server/api/gemini-proxy.js +160 -0
  58. package/src/server/api/gemini-sessions.js +397 -0
  59. package/src/server/api/gemini-statistics.js +57 -0
  60. package/src/server/api/health-check.js +118 -0
  61. package/src/server/api/mcp.js +336 -0
  62. package/src/server/api/pm2-autostart.js +269 -0
  63. package/src/server/api/projects.js +124 -0
  64. package/src/server/api/prompts.js +279 -0
  65. package/src/server/api/proxy.js +235 -0
  66. package/src/server/api/rules.js +271 -0
  67. package/src/server/api/sessions.js +595 -0
  68. package/src/server/api/settings.js +61 -0
  69. package/src/server/api/skills.js +305 -0
  70. package/src/server/api/statistics.js +91 -0
  71. package/src/server/api/terminal.js +202 -0
  72. package/src/server/api/ui-config.js +64 -0
  73. package/src/server/api/workspaces.js +407 -0
  74. package/src/server/codex-proxy-server.js +538 -0
  75. package/src/server/dev-server.js +26 -0
  76. package/src/server/gemini-proxy-server.js +518 -0
  77. package/src/server/index.js +305 -0
  78. package/src/server/proxy-server.js +469 -0
  79. package/src/server/services/agents-service.js +354 -0
  80. package/src/server/services/alias.js +71 -0
  81. package/src/server/services/channel-health.js +234 -0
  82. package/src/server/services/channel-scheduler.js +234 -0
  83. package/src/server/services/channels.js +347 -0
  84. package/src/server/services/codex-channels.js +625 -0
  85. package/src/server/services/codex-config.js +90 -0
  86. package/src/server/services/codex-parser.js +322 -0
  87. package/src/server/services/codex-sessions.js +665 -0
  88. package/src/server/services/codex-settings-manager.js +397 -0
  89. package/src/server/services/codex-speed-test-template.json +24 -0
  90. package/src/server/services/codex-statistics-service.js +255 -0
  91. package/src/server/services/commands-service.js +360 -0
  92. package/src/server/services/config-templates-service.js +732 -0
  93. package/src/server/services/env-checker.js +307 -0
  94. package/src/server/services/env-manager.js +300 -0
  95. package/src/server/services/favorites.js +163 -0
  96. package/src/server/services/gemini-channels.js +333 -0
  97. package/src/server/services/gemini-config.js +73 -0
  98. package/src/server/services/gemini-sessions.js +689 -0
  99. package/src/server/services/gemini-settings-manager.js +263 -0
  100. package/src/server/services/gemini-statistics-service.js +253 -0
  101. package/src/server/services/health-check.js +399 -0
  102. package/src/server/services/mcp-service.js +1188 -0
  103. package/src/server/services/prompts-service.js +492 -0
  104. package/src/server/services/proxy-runtime.js +79 -0
  105. package/src/server/services/pty-manager.js +435 -0
  106. package/src/server/services/rules-service.js +401 -0
  107. package/src/server/services/session-cache.js +127 -0
  108. package/src/server/services/session-converter.js +577 -0
  109. package/src/server/services/sessions.js +757 -0
  110. package/src/server/services/settings-manager.js +163 -0
  111. package/src/server/services/skill-service.js +965 -0
  112. package/src/server/services/speed-test.js +545 -0
  113. package/src/server/services/statistics-service.js +386 -0
  114. package/src/server/services/terminal-commands.js +155 -0
  115. package/src/server/services/terminal-config.js +140 -0
  116. package/src/server/services/terminal-detector.js +306 -0
  117. package/src/server/services/ui-config.js +130 -0
  118. package/src/server/services/workspace-service.js +662 -0
  119. package/src/server/utils/pricing.js +41 -0
  120. package/src/server/websocket-server.js +557 -0
  121. package/src/ui/menu.js +129 -0
  122. package/src/ui/prompts.js +100 -0
  123. package/src/utils/format.js +43 -0
  124. package/src/utils/port-helper.js +94 -0
  125. package/src/utils/session.js +239 -0
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Agents 服务
3
+ *
4
+ * 管理 Claude Code 自定义代理的 CRUD 操作
5
+ * 代理目录:
6
+ * - 用户级: ~/.claude/agents/
7
+ * - 项目级: .claude/agents/
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const os = require('os');
13
+
14
+ // 代理目录路径
15
+ const USER_AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents');
16
+
17
+ /**
18
+ * 确保目录存在
19
+ */
20
+ function ensureDir(dirPath) {
21
+ if (!fs.existsSync(dirPath)) {
22
+ fs.mkdirSync(dirPath, { recursive: true });
23
+ }
24
+ }
25
+
26
+ /**
27
+ * 解析 YAML frontmatter
28
+ */
29
+ function parseFrontmatter(content) {
30
+ const result = {
31
+ frontmatter: {},
32
+ body: content
33
+ };
34
+
35
+ // 移除 BOM
36
+ content = content.trim().replace(/^\uFEFF/, '');
37
+
38
+ // 解析 YAML frontmatter
39
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
40
+ if (!match) {
41
+ return result;
42
+ }
43
+
44
+ const frontmatterText = match[1];
45
+ result.body = match[2].trim();
46
+
47
+ // 简单解析 YAML(支持基本字段)
48
+ const lines = frontmatterText.split('\n');
49
+ for (const line of lines) {
50
+ const colonIndex = line.indexOf(':');
51
+ if (colonIndex === -1) continue;
52
+
53
+ const key = line.slice(0, colonIndex).trim();
54
+ let value = line.slice(colonIndex + 1).trim();
55
+
56
+ // 去除引号
57
+ if ((value.startsWith('"') && value.endsWith('"')) ||
58
+ (value.startsWith("'") && value.endsWith("'"))) {
59
+ value = value.slice(1, -1);
60
+ }
61
+
62
+ result.frontmatter[key] = value;
63
+ }
64
+
65
+ return result;
66
+ }
67
+
68
+ /**
69
+ * 生成 frontmatter 字符串
70
+ */
71
+ function generateFrontmatter(data) {
72
+ const lines = ['---'];
73
+
74
+ // 必需字段
75
+ if (data.name) {
76
+ lines.push(`name: ${data.name}`);
77
+ }
78
+ if (data.description) {
79
+ lines.push(`description: "${data.description}"`);
80
+ }
81
+
82
+ // 可选字段
83
+ if (data.tools) {
84
+ lines.push(`tools: ${data.tools}`);
85
+ }
86
+ if (data.model) {
87
+ lines.push(`model: ${data.model}`);
88
+ }
89
+ if (data.permissionMode) {
90
+ lines.push(`permissionMode: ${data.permissionMode}`);
91
+ }
92
+ if (data.skills) {
93
+ lines.push(`skills: ${data.skills}`);
94
+ }
95
+
96
+ lines.push('---');
97
+ return lines.join('\n');
98
+ }
99
+
100
+ /**
101
+ * 递归扫描目录获取代理文件
102
+ */
103
+ function scanAgentsDir(dir, basePath, scope) {
104
+ const agents = [];
105
+
106
+ if (!fs.existsSync(dir)) {
107
+ return agents;
108
+ }
109
+
110
+ try {
111
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
112
+
113
+ for (const entry of entries) {
114
+ const fullPath = path.join(dir, entry.name);
115
+
116
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
117
+ // 递归扫描子目录
118
+ const subAgents = scanAgentsDir(fullPath, basePath, scope);
119
+ agents.push(...subAgents);
120
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
121
+ // 解析代理文件
122
+ try {
123
+ const content = fs.readFileSync(fullPath, 'utf-8');
124
+ const { frontmatter, body } = parseFrontmatter(content);
125
+
126
+ // 计算相对路径
127
+ const relativePath = path.relative(basePath, fullPath);
128
+ const fileName = entry.name.replace(/\.md$/, '');
129
+
130
+ agents.push({
131
+ name: frontmatter.name || fileName,
132
+ fileName,
133
+ scope,
134
+ path: relativePath,
135
+ fullPath,
136
+ description: frontmatter.description || '',
137
+ tools: frontmatter.tools || '',
138
+ model: frontmatter.model || '',
139
+ permissionMode: frontmatter.permissionMode || '',
140
+ skills: frontmatter.skills || '',
141
+ systemPrompt: body,
142
+ fullContent: content,
143
+ updatedAt: fs.statSync(fullPath).mtime.getTime()
144
+ });
145
+ } catch (err) {
146
+ console.warn(`[AgentsService] Failed to parse ${fullPath}:`, err.message);
147
+ }
148
+ }
149
+ }
150
+ } catch (err) {
151
+ console.error(`[AgentsService] Failed to scan ${dir}:`, err.message);
152
+ }
153
+
154
+ return agents;
155
+ }
156
+
157
+ /**
158
+ * Agents 服务类
159
+ */
160
+ class AgentsService {
161
+ constructor() {
162
+ this.userAgentsDir = USER_AGENTS_DIR;
163
+ ensureDir(this.userAgentsDir);
164
+ }
165
+
166
+ /**
167
+ * 获取所有代理列表
168
+ * @param {string} projectPath - 项目路径(可选,用于获取项目级代理)
169
+ */
170
+ listAgents(projectPath = null) {
171
+ const agents = [];
172
+
173
+ // 获取用户级代理
174
+ const userAgents = scanAgentsDir(this.userAgentsDir, this.userAgentsDir, 'user');
175
+ agents.push(...userAgents);
176
+
177
+ // 获取项目级代理(如果提供了项目路径)
178
+ if (projectPath) {
179
+ const projectAgentsDir = path.join(projectPath, '.claude', 'agents');
180
+ const projectAgents = scanAgentsDir(projectAgentsDir, projectAgentsDir, 'project');
181
+ agents.push(...projectAgents);
182
+ }
183
+
184
+ // 按名称排序
185
+ agents.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
186
+
187
+ return {
188
+ agents,
189
+ total: agents.length,
190
+ userCount: userAgents.length,
191
+ projectCount: agents.length - userAgents.length
192
+ };
193
+ }
194
+
195
+ /**
196
+ * 获取单个代理详情
197
+ */
198
+ getAgent(fileName, scope, projectPath = null) {
199
+ const baseDir = scope === 'user'
200
+ ? this.userAgentsDir
201
+ : path.join(projectPath, '.claude', 'agents');
202
+
203
+ const filePath = path.join(baseDir, `${fileName}.md`);
204
+
205
+ if (!fs.existsSync(filePath)) {
206
+ return null;
207
+ }
208
+
209
+ const content = fs.readFileSync(filePath, 'utf-8');
210
+ const { frontmatter, body } = parseFrontmatter(content);
211
+
212
+ return {
213
+ name: frontmatter.name || fileName,
214
+ fileName,
215
+ scope,
216
+ path: `${fileName}.md`,
217
+ fullPath: filePath,
218
+ description: frontmatter.description || '',
219
+ tools: frontmatter.tools || '',
220
+ model: frontmatter.model || '',
221
+ permissionMode: frontmatter.permissionMode || '',
222
+ skills: frontmatter.skills || '',
223
+ systemPrompt: body,
224
+ fullContent: content,
225
+ updatedAt: fs.statSync(filePath).mtime.getTime()
226
+ };
227
+ }
228
+
229
+ /**
230
+ * 创建代理
231
+ */
232
+ createAgent({ fileName, scope, projectPath, name, description, tools, model, permissionMode, skills, systemPrompt }) {
233
+ if (!fileName || !fileName.trim()) {
234
+ throw new Error('代理文件名不能为空');
235
+ }
236
+
237
+ // 验证文件名:只允许字母、数字、横杠、下划线
238
+ if (!/^[a-zA-Z0-9_-]+$/.test(fileName)) {
239
+ throw new Error('代理文件名只能包含字母、数字、横杠和下划线');
240
+ }
241
+
242
+ if (!name || !name.trim()) {
243
+ throw new Error('代理名称不能为空');
244
+ }
245
+
246
+ if (!description || !description.trim()) {
247
+ throw new Error('代理描述不能为空');
248
+ }
249
+
250
+ const baseDir = scope === 'user'
251
+ ? this.userAgentsDir
252
+ : path.join(projectPath, '.claude', 'agents');
253
+
254
+ ensureDir(baseDir);
255
+
256
+ const filePath = path.join(baseDir, `${fileName}.md`);
257
+
258
+ // 检查是否已存在
259
+ if (fs.existsSync(filePath)) {
260
+ throw new Error(`代理 "${fileName}" 已存在`);
261
+ }
262
+
263
+ // 生成文件内容
264
+ const frontmatterData = { name, description };
265
+ if (tools) frontmatterData.tools = tools;
266
+ if (model) frontmatterData.model = model;
267
+ if (permissionMode) frontmatterData.permissionMode = permissionMode;
268
+ if (skills) frontmatterData.skills = skills;
269
+
270
+ const content = generateFrontmatter(frontmatterData) + '\n\n' + (systemPrompt || '');
271
+
272
+ fs.writeFileSync(filePath, content, 'utf-8');
273
+
274
+ return this.getAgent(fileName, scope, projectPath);
275
+ }
276
+
277
+ /**
278
+ * 更新代理
279
+ */
280
+ updateAgent({ fileName, scope, projectPath, name, description, tools, model, permissionMode, skills, systemPrompt }) {
281
+ const baseDir = scope === 'user'
282
+ ? this.userAgentsDir
283
+ : path.join(projectPath, '.claude', 'agents');
284
+
285
+ const filePath = path.join(baseDir, `${fileName}.md`);
286
+
287
+ if (!fs.existsSync(filePath)) {
288
+ throw new Error(`代理 "${fileName}" 不存在`);
289
+ }
290
+
291
+ // 生成文件内容
292
+ const frontmatterData = {
293
+ name: name || fileName,
294
+ description: description || ''
295
+ };
296
+ if (tools) frontmatterData.tools = tools;
297
+ if (model) frontmatterData.model = model;
298
+ if (permissionMode) frontmatterData.permissionMode = permissionMode;
299
+ if (skills) frontmatterData.skills = skills;
300
+
301
+ const content = generateFrontmatter(frontmatterData) + '\n\n' + (systemPrompt || '');
302
+
303
+ fs.writeFileSync(filePath, content, 'utf-8');
304
+
305
+ return this.getAgent(fileName, scope, projectPath);
306
+ }
307
+
308
+ /**
309
+ * 删除代理
310
+ */
311
+ deleteAgent(fileName, scope, projectPath = null) {
312
+ const baseDir = scope === 'user'
313
+ ? this.userAgentsDir
314
+ : path.join(projectPath, '.claude', 'agents');
315
+
316
+ const filePath = path.join(baseDir, `${fileName}.md`);
317
+
318
+ if (!fs.existsSync(filePath)) {
319
+ return { success: false, message: '代理不存在' };
320
+ }
321
+
322
+ fs.unlinkSync(filePath);
323
+
324
+ return { success: true, message: '代理已删除' };
325
+ }
326
+
327
+ /**
328
+ * 获取统计信息
329
+ */
330
+ getStats(projectPath = null) {
331
+ const { agents, userCount, projectCount } = this.listAgents(projectPath);
332
+
333
+ // 按模型分组
334
+ const models = {};
335
+ for (const agent of agents) {
336
+ const m = agent.model || 'default';
337
+ if (!models[m]) {
338
+ models[m] = 0;
339
+ }
340
+ models[m]++;
341
+ }
342
+
343
+ return {
344
+ total: agents.length,
345
+ userCount,
346
+ projectCount,
347
+ models
348
+ };
349
+ }
350
+ }
351
+
352
+ module.exports = {
353
+ AgentsService
354
+ };
@@ -0,0 +1,71 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { PATHS } = require('../../config/paths');
4
+
5
+ const ALIAS_DIR = PATHS.base;
6
+ const ALIAS_FILE = PATHS.aliases;
7
+
8
+ // Ensure alias directory exists
9
+ function ensureAliasDir() {
10
+ if (!fs.existsSync(ALIAS_DIR)) {
11
+ fs.mkdirSync(ALIAS_DIR, { recursive: true });
12
+ }
13
+ }
14
+
15
+ // Load all aliases
16
+ function loadAliases() {
17
+ ensureAliasDir();
18
+
19
+ if (!fs.existsSync(ALIAS_FILE)) {
20
+ return {};
21
+ }
22
+
23
+ try {
24
+ const content = fs.readFileSync(ALIAS_FILE, 'utf8');
25
+ return JSON.parse(content);
26
+ } catch (error) {
27
+ console.error('Error loading aliases:', error);
28
+ return {};
29
+ }
30
+ }
31
+
32
+ // Save aliases
33
+ function saveAliases(aliases) {
34
+ ensureAliasDir();
35
+
36
+ try {
37
+ fs.writeFileSync(ALIAS_FILE, JSON.stringify(aliases, null, 2), 'utf8');
38
+ } catch (error) {
39
+ console.error('Error saving aliases:', error);
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ // Set alias for a session
45
+ function setAlias(sessionId, alias) {
46
+ const aliases = loadAliases();
47
+ aliases[sessionId] = alias;
48
+ saveAliases(aliases);
49
+ return aliases;
50
+ }
51
+
52
+ // Delete alias
53
+ function deleteAlias(sessionId) {
54
+ const aliases = loadAliases();
55
+ delete aliases[sessionId];
56
+ saveAliases(aliases);
57
+ return aliases;
58
+ }
59
+
60
+ // Get alias for a session
61
+ function getAlias(sessionId) {
62
+ const aliases = loadAliases();
63
+ return aliases[sessionId] || null;
64
+ }
65
+
66
+ module.exports = {
67
+ loadAliases,
68
+ setAlias,
69
+ deleteAlias,
70
+ getAlias
71
+ };
@@ -0,0 +1,234 @@
1
+ // 渠道健康检查和智能切换模块
2
+
3
+ const healthConfig = {
4
+ // 故障检测
5
+ failureThreshold: 3, // 连续失败3次触发冻结
6
+
7
+ // 冻结时间配置
8
+ initialFreezeTime: 60 * 1000, // 初始冻结1分钟
9
+ maxFreezeTime: 30 * 60 * 1000, // 最大冻结30分钟
10
+ freezeMultiplier: 2, // 冻结时间倍增
11
+
12
+ // 健康检测
13
+ healthCheckWindow: 5, // 健康检测需要连续5次成功
14
+ };
15
+
16
+ // 渠道健康状态
17
+ const channelHealth = new Map(); // `${source}:${channelId}` → health info
18
+
19
+ // 冻结回调(用于通知调度器解绑会话)
20
+ let onChannelFrozenCallback = null;
21
+
22
+ /**
23
+ * 设置渠道冻结时的回调
24
+ */
25
+ function setOnChannelFrozen(callback) {
26
+ onChannelFrozenCallback = callback;
27
+ }
28
+
29
+ /**
30
+ * 初始化渠道健康信息
31
+ */
32
+ function makeKey(source, channelId) {
33
+ return `${source || 'claude'}:${channelId}`;
34
+ }
35
+
36
+ function initChannelHealth(channelId, source = 'claude') {
37
+ const key = makeKey(source, channelId);
38
+ if (!channelHealth.has(key)) {
39
+ channelHealth.set(key, {
40
+ status: 'healthy', // healthy, frozen, checking
41
+ consecutiveFailures: 0, // 连续失败次数
42
+ consecutiveSuccesses: 0, // 连续成功次数
43
+ totalFailures: 0, // 总失败次数
44
+ totalSuccesses: 0, // 总成功次数
45
+ freezeUntil: 0, // 冻结到期时间
46
+ nextFreezeTime: healthConfig.initialFreezeTime,
47
+ lastCheckTime: null, // 最后检查时间
48
+ source
49
+ });
50
+ }
51
+ return channelHealth.get(key);
52
+ }
53
+
54
+ /**
55
+ * 记录成功请求
56
+ */
57
+ function recordSuccess(channelId, source = 'claude') {
58
+ const health = initChannelHealth(channelId, source);
59
+ const now = Date.now();
60
+
61
+ health.totalSuccesses++;
62
+ health.consecutiveSuccesses++;
63
+ health.consecutiveFailures = 0;
64
+ health.lastCheckTime = now;
65
+
66
+ // 如果在检测中状态,检查是否可以恢复
67
+ if (health.status === 'checking') {
68
+ if (health.consecutiveSuccesses >= healthConfig.healthCheckWindow) {
69
+ // 恢复健康状态
70
+ health.status = 'healthy';
71
+ health.nextFreezeTime = healthConfig.initialFreezeTime; // 重置冻结时间
72
+ console.log(`[ChannelHealth] Channel ${channelId} recovered and marked as healthy`);
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 记录失败请求
79
+ */
80
+ function recordFailure(channelId, source = 'claude', error) {
81
+ const health = initChannelHealth(channelId, source);
82
+ const now = Date.now();
83
+
84
+ health.totalFailures++;
85
+ health.consecutiveFailures++;
86
+ health.consecutiveSuccesses = 0;
87
+ health.lastCheckTime = now;
88
+
89
+ // 如果当前是健康状态或检测中状态,检查是否需要冻结
90
+ if (health.status === 'healthy' || health.status === 'checking') {
91
+ if (health.consecutiveFailures >= healthConfig.failureThreshold) {
92
+ // 触发冻结
93
+ const previousStatus = health.status;
94
+ health.status = 'frozen';
95
+ health.freezeUntil = now + health.nextFreezeTime;
96
+
97
+ const freezeMinutes = Math.round(health.nextFreezeTime / 60000);
98
+ console.warn(`[ChannelHealth] Channel ${channelId} frozen due to ${health.consecutiveFailures} consecutive failures (was ${previousStatus}). Frozen for ${freezeMinutes} minutes`);
99
+
100
+ // 更新下次冻结时间(翻倍,不超过最大值)
101
+ health.nextFreezeTime = Math.min(
102
+ health.nextFreezeTime * healthConfig.freezeMultiplier,
103
+ healthConfig.maxFreezeTime
104
+ );
105
+
106
+ // 触发冻结回调(通知调度器解绑会话)
107
+ if (onChannelFrozenCallback) {
108
+ onChannelFrozenCallback(source || 'claude', channelId);
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 检查渠道是否可用
116
+ */
117
+ function isChannelAvailable(channelId, source = 'claude') {
118
+ const key = makeKey(source, channelId);
119
+ const health = channelHealth.get(key);
120
+ if (!health) return true;
121
+
122
+ const now = Date.now();
123
+
124
+ switch (health.status) {
125
+ case 'healthy':
126
+ return true;
127
+
128
+ case 'frozen':
129
+ // 检查冻结时间是否到期
130
+ if (now >= health.freezeUntil) {
131
+ // 进入检测状态
132
+ health.status = 'checking';
133
+ health.consecutiveSuccesses = 0;
134
+ console.log(`[ChannelHealth] Channel ${channelId} freeze expired, entering checking mode`);
135
+ return true; // 允许一个请求用于健康检测
136
+ }
137
+ return false;
138
+
139
+ case 'checking':
140
+ // 在检测中的渠道可用,等待成功记录
141
+ return true;
142
+
143
+ default:
144
+ return true;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * 从渠道列表中过滤出可用的渠道
150
+ */
151
+ function getAvailableChannels(channels, source = 'claude') {
152
+ return channels.filter(channel => isChannelAvailable(channel.id, source));
153
+ }
154
+
155
+ /**
156
+ * 获取渠道健康状态(用于前端显示)
157
+ */
158
+ function getChannelHealthStatus(channelId, source = 'claude') {
159
+ const key = makeKey(source, channelId);
160
+ const health = channelHealth.get(key);
161
+ if (!health) {
162
+ return {
163
+ status: 'healthy',
164
+ statusText: '健康',
165
+ statusColor: '#18a058',
166
+ consecutiveFailures: 0,
167
+ consecutiveSuccesses: 0,
168
+ totalFailures: 0,
169
+ totalSuccesses: 0,
170
+ freezeUntil: null,
171
+ freezeRemaining: 0,
172
+ };
173
+ }
174
+
175
+ const now = Date.now();
176
+ const freezeRemaining = Math.max(0, health.freezeUntil - now);
177
+
178
+ const statusMap = {
179
+ 'healthy': { text: '健康', color: '#18a058' },
180
+ 'frozen': { text: '冻结', color: '#d03050' },
181
+ 'checking': { text: '检测中', color: '#f0a020' }
182
+ };
183
+
184
+ return {
185
+ status: health.status,
186
+ statusText: statusMap[health.status]?.text || '未知',
187
+ statusColor: statusMap[health.status]?.color || '#909399',
188
+ consecutiveFailures: health.consecutiveFailures,
189
+ consecutiveSuccesses: health.consecutiveSuccesses,
190
+ totalFailures: health.totalFailures,
191
+ totalSuccesses: health.totalSuccesses,
192
+ freezeUntil: health.freezeUntil,
193
+ freezeRemaining: Math.ceil(freezeRemaining / 1000), // 剩余秒数
194
+ };
195
+ }
196
+
197
+ /**
198
+ * 获取所有渠道的健康状态
199
+ */
200
+ function getAllChannelHealthStatus(source = 'claude') {
201
+ const result = {};
202
+ for (const [key] of channelHealth) {
203
+ const [keySource, channelId] = key.split(':');
204
+ if (keySource === (source || 'claude')) {
205
+ result[channelId] = getChannelHealthStatus(channelId, keySource);
206
+ }
207
+ }
208
+ return result;
209
+ }
210
+
211
+ /**
212
+ * 手动重置渠道健康状态(用于测试或管理员操作)
213
+ */
214
+ function resetChannelHealth(channelId, source = 'claude') {
215
+ const health = initChannelHealth(channelId, source);
216
+ health.status = 'healthy';
217
+ health.consecutiveFailures = 0;
218
+ health.consecutiveSuccesses = 0;
219
+ health.freezeUntil = 0;
220
+ health.nextFreezeTime = healthConfig.initialFreezeTime;
221
+ console.log(`[ChannelHealth] Channel ${channelId} health status reset`);
222
+ }
223
+
224
+ module.exports = {
225
+ recordSuccess,
226
+ recordFailure,
227
+ isChannelAvailable,
228
+ getAvailableChannels,
229
+ getChannelHealthStatus,
230
+ getAllChannelHealthStatus,
231
+ resetChannelHealth,
232
+ setOnChannelFrozen,
233
+ healthConfig,
234
+ };