coding-tool-x 3.3.9 → 3.4.1

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 (33) hide show
  1. package/dist/web/assets/{Analytics-D6LzK9hk.js → Analytics-CbGxotgz.js} +4 -4
  2. package/dist/web/assets/Analytics-RNn1BUbG.css +1 -0
  3. package/dist/web/assets/{ConfigTemplates-BUDYuxRi.js → ConfigTemplates-oP6nrFEb.js} +1 -1
  4. package/dist/web/assets/{Home-D7KX7iF8.js → Home-DMntmEvh.js} +1 -1
  5. package/dist/web/assets/{PluginManager-DTgQ--vB.js → PluginManager-BUC_c7nH.js} +1 -1
  6. package/dist/web/assets/{ProjectList-DMCiGmCT.js → ProjectList-CW8J49n7.js} +1 -1
  7. package/dist/web/assets/{SessionList-CRBsdVRe.js → SessionList-7lYnF92v.js} +1 -1
  8. package/dist/web/assets/{SkillManager-DMwx2Q4k.js → SkillManager-Cs08216i.js} +1 -1
  9. package/dist/web/assets/{WorkspaceManager-DapB4ljL.js → WorkspaceManager-CY-oGtyB.js} +1 -1
  10. package/dist/web/assets/{index-D_5dRFOL.css → index-5qy5NMIP.css} +1 -1
  11. package/dist/web/assets/index-ClCqKpvX.js +2 -0
  12. package/dist/web/index.html +2 -2
  13. package/package.json +6 -2
  14. package/src/server/api/statistics.js +4 -4
  15. package/src/server/api/workspaces.js +1 -3
  16. package/src/server/codex-proxy-server.js +4 -92
  17. package/src/server/gemini-proxy-server.js +5 -28
  18. package/src/server/opencode-proxy-server.js +3 -93
  19. package/src/server/proxy-server.js +2 -57
  20. package/src/server/services/base/base-channel-service.js +247 -0
  21. package/src/server/services/base/proxy-utils.js +152 -0
  22. package/src/server/services/channel-health.js +30 -19
  23. package/src/server/services/channels.js +125 -293
  24. package/src/server/services/codex-channels.js +149 -517
  25. package/src/server/services/codex-env-manager.js +100 -67
  26. package/src/server/services/gemini-channels.js +2 -7
  27. package/src/server/services/oauth-credentials-service.js +12 -2
  28. package/src/server/services/opencode-channels.js +7 -9
  29. package/src/server/services/repo-scanner-base.js +1 -0
  30. package/src/server/services/statistics-service.js +5 -1
  31. package/src/server/services/workspace-service.js +100 -155
  32. package/dist/web/assets/Analytics-DuYvId7u.css +0 -1
  33. package/dist/web/assets/index-CL-qpoJ_.js +0 -2
@@ -5,14 +5,14 @@
5
5
  <link rel="icon" href="/favicon.ico">
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
7
  <title>CC-TOOL - ClaudeCode增强工作助手</title>
8
- <script type="module" crossorigin src="/assets/index-CL-qpoJ_.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-ClCqKpvX.js"></script>
9
9
  <link rel="modulepreload" crossorigin href="/assets/markdown-DyTJGI4N.js">
10
10
  <link rel="modulepreload" crossorigin href="/assets/vue-vendor-3bf-fPGP.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/vendors-CKPV1OAU.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/naive-ui-Bdxp09n2.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/icons-B5Pl4lrD.js">
14
14
  <link rel="stylesheet" crossorigin href="/assets/markdown-BfC0goYb.css">
15
- <link rel="stylesheet" crossorigin href="/assets/index-D_5dRFOL.css">
15
+ <link rel="stylesheet" crossorigin href="/assets/index-5qy5NMIP.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-tool-x",
3
- "version": "3.3.9",
3
+ "version": "3.4.1",
4
4
  "description": "Vibe Coding 增强工作助手 - 智能会话管理、动态渠道切换、全局搜索、实时监控",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -14,6 +14,8 @@
14
14
  "test:codex-agents": "node scripts/test-codex-agents.js",
15
15
  "test:skills": "node scripts/test-skill-providers.js",
16
16
  "test:plugins-market": "node scripts/test-plugin-market-cache.js",
17
+ "test:unit": "vitest run",
18
+ "test:unit:watch": "vitest",
17
19
  "test:windows": "node scripts/test-windows-regression.js",
18
20
  "benchmark:codex": "node scripts/benchmark-codex-loading.js",
19
21
  "build:web": "cd src/web && npm run build",
@@ -77,7 +79,9 @@
77
79
  "systeminformation": "^5.31.4"
78
80
  },
79
81
  "devDependencies": {
80
- "nodemon": "^3.0.2"
82
+ "@vitest/coverage-v8": "^4.1.0",
83
+ "nodemon": "^3.0.2",
84
+ "vitest": "^4.1.0"
81
85
  },
82
86
  "repository": {
83
87
  "type": "git",
@@ -151,8 +151,8 @@ router.get('/trend', async (req, res) => {
151
151
  return res.status(400).json({ error: 'Hour granularity is limited to 7 days' });
152
152
  }
153
153
 
154
- if (diffDays > 90) {
155
- return res.status(400).json({ error: 'Date range cannot exceed 90 days' });
154
+ if (diffDays > 365) {
155
+ return res.status(400).json({ error: 'Date range cannot exceed 365 days' });
156
156
  }
157
157
 
158
158
  const filters = {
@@ -204,8 +204,8 @@ router.get('/trend/export', async (req, res) => {
204
204
  return res.status(400).json({ error: 'Hour granularity is limited to 7 days' });
205
205
  }
206
206
 
207
- if (diffDays > 90) {
208
- return res.status(400).json({ error: 'Date range cannot exceed 90 days' });
207
+ if (diffDays > 365) {
208
+ return res.status(400).json({ error: 'Date range cannot exceed 365 days' });
209
209
  }
210
210
 
211
211
  const result = await getTrendStatistics({ startDate, endDate, granularity, step, groupBy, metric });
@@ -402,12 +402,10 @@ router.post('/:id/projects', (req, res) => {
402
402
  router.delete('/:id/projects/:projectName', (req, res) => {
403
403
  try {
404
404
  const { id, projectName } = req.params;
405
- const removeWorktrees = req.query.removeWorktrees === 'true';
406
405
 
407
406
  const workspace = workspaceService.removeProjectFromWorkspace(
408
407
  id,
409
- projectName,
410
- removeWorktrees
408
+ projectName
411
409
  );
412
410
 
413
411
  res.json({
@@ -14,6 +14,7 @@ const { createDecodedStream } = require('./services/response-decoder');
14
14
  const { getEffectiveApiKey } = require('./services/codex-channels');
15
15
  const { persistProxyRequestSnapshot } = require('./services/request-logger');
16
16
  const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
17
+ const { redirectModel, resolveTargetUrl } = require('./services/base/proxy-utils');
17
18
 
18
19
  let proxyServer = null;
19
20
  let proxyApp = null;
@@ -46,99 +47,10 @@ const PRICING = {
46
47
  const CODEX_BASE_PRICING = DEFAULT_CONFIG.pricing.codex;
47
48
  const ONE_MILLION = 1000000;
48
49
 
49
- /**
50
- * 检测模型层级
51
- * @param {string} modelName - 模型名称
52
- * @returns {string|null} 模型层级 (opus/sonnet/haiku) 或 null
53
- */
54
- function detectModelTier(modelName) {
55
- if (!modelName) return null;
56
- const lower = modelName.toLowerCase();
57
- if (lower.includes('opus')) return 'opus';
58
- if (lower.includes('sonnet')) return 'sonnet';
59
- if (lower.includes('haiku')) return 'haiku';
60
- return null;
61
- }
62
-
63
- /**
64
- * 应用模型重定向
65
- * @param {string} originalModel - 原始模型名称
66
- * @param {object} channel - 渠道对象,包含 modelConfig 和 modelRedirects
67
- * @returns {string} 重定向后的模型名称
68
- */
69
- function redirectModel(originalModel, channel) {
70
- if (!originalModel) return originalModel;
71
-
72
- // 优先使用新的 modelRedirects 数组格式
73
- const modelRedirects = channel?.modelRedirects;
74
- if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
75
- for (const rule of modelRedirects) {
76
- if (rule.from && rule.to && rule.from === originalModel) {
77
- return rule.to;
78
- }
79
- }
80
- }
81
-
82
- // 向后兼容:使用旧的 modelConfig 格式
83
- const modelConfig = channel?.modelConfig;
84
- if (!modelConfig) return originalModel;
85
-
86
- const tier = detectModelTier(originalModel);
50
+ // detectModelTier, redirectModel, resolveTargetUrl imported from services/base/proxy-utils
87
51
 
88
- // 优先级:层级特定配置 > 通用模型覆盖
89
- if (tier === 'opus' && modelConfig.opusModel) {
90
- return modelConfig.opusModel;
91
- }
92
- if (tier === 'sonnet' && modelConfig.sonnetModel) {
93
- return modelConfig.sonnetModel;
94
- }
95
- if (tier === 'haiku' && modelConfig.haikuModel) {
96
- return modelConfig.haikuModel;
97
- }
98
-
99
- // 回退到通用模型覆盖
100
- if (modelConfig.model) {
101
- return modelConfig.model;
102
- }
103
-
104
- return originalModel;
105
- }
106
-
107
- /**
108
- * 解析 Codex 代理目标 URL
109
- *
110
- * Codex CLI 发送请求到我们的代理时,请求路径格式:
111
- * - /v1/responses (OpenAI Responses API)
112
- * - /v1/chat/completions (OpenAI Chat Completions API)
113
- *
114
- * 渠道配置的 base_url 可能是:
115
- * - https://api.openai.com/v1
116
- * - https://example.com/openai/v1
117
- * - https://example.com
118
- *
119
- * 最终转发目标示例:
120
- * - base_url: https://example.com/openai/v1, path: /v1/responses
121
- * -> target: https://example.com/openai, 最终: https://example.com/openai/v1/responses
122
- *
123
- * 这个函数返回要传给 http-proxy 的 target,http-proxy 会自动拼接 req.url
124
- */
125
- function resolveCodexTarget(baseUrl = '', requestPath = '') {
126
- let target = baseUrl || '';
127
-
128
- // 移除末尾斜杠
129
- if (target.endsWith('/')) {
130
- target = target.slice(0, -1);
131
- }
132
-
133
- // 核心逻辑:避免 /v1/v1 重复
134
- // 如果 base_url 以 /v1 结尾,且请求路径以 /v1 开头,去掉 base_url 的 /v1
135
- // 因为 http-proxy 会将 requestPath 追加到 target 后面
136
- if (target.endsWith('/v1') && requestPath.startsWith('/v1')) {
137
- target = target.slice(0, -3);
138
- }
139
-
140
- return target;
141
- }
52
+ // resolveCodexTarget replaced by resolveTargetUrl from proxy-utils
53
+ const resolveCodexTarget = resolveTargetUrl;
142
54
 
143
55
  /**
144
56
  * 计算请求成本
@@ -14,6 +14,7 @@ const { createDecodedStream } = require('./services/response-decoder');
14
14
  const { getEffectiveApiKey } = require('./services/gemini-channels');
15
15
  const { persistProxyRequestSnapshot } = require('./services/request-logger');
16
16
  const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
17
+ const { redirectModel: redirectModelBase, resolveTargetUrl } = require('./services/base/proxy-utils');
17
18
 
18
19
  let proxyServer = null;
19
20
  let proxyApp = null;
@@ -45,36 +46,12 @@ const PRICING = {
45
46
  const GEMINI_BASE_PRICING = DEFAULT_CONFIG.pricing.gemini;
46
47
  const ONE_MILLION = 1000000;
47
48
 
48
- function resolveGeminiTarget(baseUrl = '', requestPath = '') {
49
- let target = baseUrl || '';
50
- if (target.endsWith('/')) {
51
- target = target.slice(0, -1);
52
- }
53
- if (target.endsWith('/v1') && requestPath.startsWith('/v1')) {
54
- target = target.slice(0, -3);
55
- }
56
- return target;
57
- }
49
+ // resolveGeminiTarget replaced by resolveTargetUrl from proxy-utils
50
+ const resolveGeminiTarget = resolveTargetUrl;
58
51
 
59
- /**
60
- * 应用模型重定向(精确匹配)
61
- * @param {string} originalModel - 原始模型名称
62
- * @param {object} channel - 渠道对象,包含 modelRedirects 数组
63
- * @returns {string} 重定向后的模型名称
64
- */
52
+ // Gemini uses exact-match only redirect (no tier fallback)
65
53
  function redirectModel(originalModel, channel) {
66
- if (!originalModel) return originalModel;
67
-
68
- const modelRedirects = channel?.modelRedirects;
69
- if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
70
- for (const rule of modelRedirects) {
71
- if (rule.from && rule.to && rule.from === originalModel) {
72
- return rule.to;
73
- }
74
- }
75
- }
76
-
77
- return originalModel;
54
+ return redirectModelBase(originalModel, channel, { useTierFallback: false });
78
55
  }
79
56
 
80
57
  /**
@@ -21,6 +21,7 @@ const { getEnabledChannels, getEffectiveApiKey } = require('./services/opencode-
21
21
  const { persistProxyRequestSnapshot, loadClaudeRequestTemplate } = require('./services/request-logger');
22
22
  const { probeModelAvailability, fetchModelsFromProvider } = require('./services/model-detector');
23
23
  const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
24
+ const { redirectModel, resolveTargetUrl } = require('./services/base/proxy-utils');
24
25
 
25
26
  let proxyServer = null;
26
27
  let proxyApp = null;
@@ -92,99 +93,8 @@ let cachedClaudeUserId = '';
92
93
  let cachedClaudeRequestTemplate = null;
93
94
  let cachedClaudeRequestTemplateAt = 0;
94
95
 
95
- /**
96
- * 检测模型层级
97
- * @param {string} modelName - 模型名称
98
- * @returns {string|null} 模型层级 (opus/sonnet/haiku) 或 null
99
- */
100
- function detectModelTier(modelName) {
101
- if (!modelName) return null;
102
- const lower = modelName.toLowerCase();
103
- if (lower.includes('opus')) return 'opus';
104
- if (lower.includes('sonnet')) return 'sonnet';
105
- if (lower.includes('haiku')) return 'haiku';
106
- return null;
107
- }
108
-
109
- /**
110
- * 应用模型重定向
111
- * @param {string} originalModel - 原始模型名称
112
- * @param {object} channel - 渠道对象,包含 modelConfig 和 modelRedirects
113
- * @returns {string} 重定向后的模型名称
114
- */
115
- function redirectModel(originalModel, channel) {
116
- if (!originalModel) return originalModel;
117
-
118
- // 优先使用新的 modelRedirects 数组格式
119
- const modelRedirects = channel?.modelRedirects;
120
- if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
121
- for (const rule of modelRedirects) {
122
- if (rule.from && rule.to && rule.from === originalModel) {
123
- return rule.to;
124
- }
125
- }
126
- }
127
-
128
- // 向后兼容:使用旧的 modelConfig 格式
129
- const modelConfig = channel?.modelConfig;
130
- if (!modelConfig) return originalModel;
131
-
132
- const tier = detectModelTier(originalModel);
133
-
134
- // 优先级:层级特定配置 > 通用模型覆盖
135
- if (tier === 'opus' && modelConfig.opusModel) {
136
- return modelConfig.opusModel;
137
- }
138
- if (tier === 'sonnet' && modelConfig.sonnetModel) {
139
- return modelConfig.sonnetModel;
140
- }
141
- if (tier === 'haiku' && modelConfig.haikuModel) {
142
- return modelConfig.haikuModel;
143
- }
144
-
145
- // 回退到通用模型覆盖
146
- if (modelConfig.model) {
147
- return modelConfig.model;
148
- }
149
-
150
- return originalModel;
151
- }
152
-
153
- /**
154
- * 解析 OpenCode 代理目标 URL
155
- *
156
- * OpenCode CLI 发送请求到我们的代理时,请求路径格式:
157
- * - /v1/responses (OpenAI Responses API)
158
- * - /v1/chat/completions (OpenAI Chat Completions API)
159
- *
160
- * 渠道配置的 base_url 可能是:
161
- * - https://api.openai.com/v1
162
- * - https://example.com/openai/v1
163
- * - https://example.com
164
- *
165
- * 最终转发目标示例:
166
- * - base_url: https://example.com/openai/v1, path: /v1/responses
167
- * -> target: https://example.com/openai, 最终: https://example.com/openai/v1/responses
168
- *
169
- * 这个函数返回要传给 http-proxy 的 target,http-proxy 会自动拼接 req.url
170
- */
171
- function resolveOpenCodeTarget(baseUrl = '', requestPath = '') {
172
- let target = baseUrl || '';
173
-
174
- // 移除末尾斜杠
175
- if (target.endsWith('/')) {
176
- target = target.slice(0, -1);
177
- }
178
-
179
- // 核心逻辑:避免 /v1/v1 重复
180
- // 如果 base_url 以 /v1 结尾,且请求路径以 /v1 开头,去掉 base_url 的 /v1
181
- // 因为 http-proxy 会将 requestPath 追加到 target 后面
182
- if (target.endsWith('/v1') && requestPath.startsWith('/v1')) {
183
- target = target.slice(0, -3);
184
- }
185
-
186
- return target;
187
- }
96
+ // detectModelTier, redirectModel, resolveTargetUrl imported from services/base/proxy-utils
97
+ const resolveOpenCodeTarget = resolveTargetUrl;
188
98
 
189
99
  /**
190
100
  * 计算请求成本
@@ -19,6 +19,7 @@ const eventBus = require('../plugins/event-bus');
19
19
  const { getEffectiveApiKey } = require('./services/channels');
20
20
  const { persistProxyRequestSnapshot, persistClaudeRequestTemplate } = require('./services/request-logger');
21
21
  const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
22
+ const { redirectModel } = require('./services/base/proxy-utils');
22
23
 
23
24
  let proxyServer = null;
24
25
  let proxyApp = null;
@@ -34,63 +35,7 @@ const printedRedirectCache = new Map();
34
35
  const CLAUDE_BASE_PRICING = DEFAULT_CONFIG.pricing.claude;
35
36
  const ONE_MILLION = 1000000;
36
37
 
37
- /**
38
- * 检测模型层级
39
- * @param {string} modelName - 模型名称
40
- * @returns {string|null} 模型层级 (opus/sonnet/haiku) 或 null
41
- */
42
- function detectModelTier(modelName) {
43
- if (!modelName) return null;
44
- const lower = modelName.toLowerCase();
45
- if (lower.includes('opus')) return 'opus';
46
- if (lower.includes('sonnet')) return 'sonnet';
47
- if (lower.includes('haiku')) return 'haiku';
48
- return null;
49
- }
50
-
51
- /**
52
- * 应用模型重定向
53
- * @param {string} originalModel - 原始模型名称
54
- * @param {object} channel - 渠道对象,包含 modelConfig 和 modelRedirects
55
- * @returns {string} 重定向后的模型名称
56
- */
57
- function redirectModel(originalModel, channel) {
58
- if (!originalModel) return originalModel;
59
-
60
- // 优先使用新的 modelRedirects 数组格式
61
- const modelRedirects = channel?.modelRedirects;
62
- if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
63
- for (const rule of modelRedirects) {
64
- if (rule.from && rule.to && rule.from === originalModel) {
65
- return rule.to;
66
- }
67
- }
68
- }
69
-
70
- // 向后兼容:使用旧的 modelConfig 格式
71
- const modelConfig = channel?.modelConfig;
72
- if (!modelConfig) return originalModel;
73
-
74
- const tier = detectModelTier(originalModel);
75
-
76
- // 优先级:层级特定配置 > 通用模型覆盖
77
- if (tier === 'opus' && modelConfig.opusModel) {
78
- return modelConfig.opusModel;
79
- }
80
- if (tier === 'sonnet' && modelConfig.sonnetModel) {
81
- return modelConfig.sonnetModel;
82
- }
83
- if (tier === 'haiku' && modelConfig.haikuModel) {
84
- return modelConfig.haikuModel;
85
- }
86
-
87
- // 回退到通用模型覆盖
88
- if (modelConfig.model) {
89
- return modelConfig.model;
90
- }
91
-
92
- return originalModel;
93
- }
38
+ // detectModelTier and redirectModel imported from services/base/proxy-utils
94
39
 
95
40
  /**
96
41
  * 计算请求成本
@@ -0,0 +1,247 @@
1
+ /**
2
+ * BaseChannelService - 四平台渠道管理的公共基类
3
+ *
4
+ * 提取 channels.js / codex-channels.js / gemini-channels.js / opencode-channels.js
5
+ * 中的共享 CRUD、启用/禁用、单渠道强制等逻辑。
6
+ *
7
+ * 子类通过覆写钩子方法实现平台差异化行为。
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const crypto = require('crypto');
13
+ const { normalizeGatewaySourceType, normalizeNumber } = require('./proxy-utils');
14
+
15
+ class BaseChannelService {
16
+ /**
17
+ * @param {object} config
18
+ * @param {string} config.platform - 'claude'|'codex'|'gemini'|'opencode'
19
+ * @param {string} config.channelsFilePath - 渠道数据文件路径
20
+ * @param {string} [config.defaultGatewaySource] - 默认网关来源类型
21
+ * @param {Function} [config.isProxyRunning] - 返回代理是否运行中
22
+ */
23
+ constructor(config) {
24
+ this.platform = config.platform;
25
+ this.channelsFilePath = config.channelsFilePath;
26
+ this.defaultGatewaySource = config.defaultGatewaySource || config.platform;
27
+ this._isProxyRunning = config.isProxyRunning || (() => false);
28
+ }
29
+
30
+ // ── 文件 I/O ──
31
+
32
+ _ensureDir() {
33
+ const dir = path.dirname(this.channelsFilePath);
34
+ if (!fs.existsSync(dir)) {
35
+ fs.mkdirSync(dir, { recursive: true });
36
+ }
37
+ }
38
+
39
+ loadChannels() {
40
+ this._ensureDir();
41
+ try {
42
+ if (fs.existsSync(this.channelsFilePath)) {
43
+ const raw = JSON.parse(fs.readFileSync(this.channelsFilePath, 'utf8'));
44
+ const channels = Array.isArray(raw?.channels) ? raw.channels : [];
45
+ return { channels: channels.map(ch => this._applyDefaults(ch)) };
46
+ }
47
+ } catch (err) {
48
+ console.error(`[${this.platform}-channels] Error loading channels:`, err.message);
49
+ }
50
+ return { channels: [] };
51
+ }
52
+
53
+ saveChannels(data) {
54
+ this._ensureDir();
55
+ fs.writeFileSync(this.channelsFilePath, JSON.stringify(data, null, 2), 'utf8');
56
+ }
57
+
58
+ // ── 查询 ──
59
+
60
+ getChannels() {
61
+ return this.loadChannels();
62
+ }
63
+
64
+ getEnabledChannels() {
65
+ const data = this.loadChannels();
66
+ return data.channels.filter(ch => ch.enabled !== false);
67
+ }
68
+
69
+ // ── CRUD ──
70
+
71
+ createChannel(fields) {
72
+ const data = this.loadChannels();
73
+
74
+ // 子类可覆写的唯一性校验
75
+ this._validateUniqueness(data.channels, fields);
76
+
77
+ const channel = this._applyDefaults({
78
+ id: this._generateId(),
79
+ ...fields,
80
+ createdAt: Date.now(),
81
+ updatedAt: Date.now(),
82
+ });
83
+
84
+ data.channels.push(channel);
85
+
86
+ // 单渠道强制:代理未运行时只允许一个渠道启用
87
+ if (channel.enabled && !this._isProxyRunning()) {
88
+ this._enforceSingleChannel(data.channels, data.channels.length - 1);
89
+ }
90
+
91
+ this.saveChannels(data);
92
+ this._onAfterCreate(channel, data.channels);
93
+ return channel;
94
+ }
95
+
96
+ updateChannel(channelId, updates) {
97
+ const data = this.loadChannels();
98
+ const index = data.channels.findIndex(ch => ch.id === channelId);
99
+ if (index === -1) {
100
+ throw new Error('Channel not found');
101
+ }
102
+
103
+ const oldChannel = data.channels[index];
104
+
105
+ // 子类可覆写的唯一性校验(排除自身)
106
+ this._validateUniqueness(data.channels, updates, channelId);
107
+
108
+ const nextChannel = this._applyDefaults({
109
+ ...oldChannel,
110
+ ...updates,
111
+ id: channelId,
112
+ updatedAt: Date.now(),
113
+ });
114
+ data.channels[index] = nextChannel;
115
+
116
+ // 单渠道强制
117
+ const isProxyRunning = this._isProxyRunning();
118
+ if (!isProxyRunning && nextChannel.enabled && !oldChannel.enabled) {
119
+ this._enforceSingleChannel(data.channels, index);
120
+ }
121
+
122
+ this.saveChannels(data);
123
+ this._onAfterUpdate(oldChannel, nextChannel, data.channels);
124
+ return nextChannel;
125
+ }
126
+
127
+ deleteChannel(channelId) {
128
+ const data = this.loadChannels();
129
+ const index = data.channels.findIndex(ch => ch.id === channelId);
130
+ if (index === -1) {
131
+ throw new Error('Channel not found');
132
+ }
133
+
134
+ const removed = data.channels.splice(index, 1)[0];
135
+ this.saveChannels(data);
136
+ this._onAfterDelete(removed, data.channels);
137
+ return { success: true };
138
+ }
139
+
140
+ // ── 启用/禁用 ──
141
+
142
+ disableAllChannels() {
143
+ const data = this.loadChannels();
144
+ data.channels.forEach(ch => { ch.enabled = false; });
145
+ this.saveChannels(data);
146
+ }
147
+
148
+ applyChannelToSettings(channelId) {
149
+ const data = this.loadChannels();
150
+ const channel = data.channels.find(ch => ch.id === channelId);
151
+ if (!channel) {
152
+ throw new Error('Channel not found');
153
+ }
154
+
155
+ // 单渠道模式:只启用目标渠道
156
+ data.channels.forEach(ch => {
157
+ ch.enabled = ch.id === channelId;
158
+ });
159
+ this.saveChannels(data);
160
+ this._applyToNativeSettings(channel);
161
+ return channel;
162
+ }
163
+
164
+ // ── 排序 ──
165
+
166
+ saveChannelOrder(order) {
167
+ if (!Array.isArray(order)) return;
168
+ const data = this.loadChannels();
169
+ const channelMap = new Map(data.channels.map(ch => [ch.id, ch]));
170
+ const ordered = [];
171
+ for (const id of order) {
172
+ const ch = channelMap.get(id);
173
+ if (ch) {
174
+ ordered.push(ch);
175
+ channelMap.delete(id);
176
+ }
177
+ }
178
+ // 未在 order 中的渠道追加到末尾
179
+ for (const ch of channelMap.values()) {
180
+ ordered.push(ch);
181
+ }
182
+ data.channels = ordered;
183
+ this.saveChannels(data);
184
+ }
185
+
186
+ // ── API Key ──
187
+
188
+ getEffectiveApiKey(channel) {
189
+ return channel?.apiKey || null;
190
+ }
191
+
192
+ // ── 内部方法 ──
193
+
194
+ _generateId() {
195
+ return crypto.randomUUID();
196
+ }
197
+
198
+ _enforceSingleChannel(channels, enabledIndex) {
199
+ channels.forEach((ch, i) => {
200
+ if (i !== enabledIndex && ch.enabled) {
201
+ ch.enabled = false;
202
+ }
203
+ });
204
+ const name = channels[enabledIndex]?.name || channels[enabledIndex]?.id;
205
+ console.log(`[${this.platform}] Single-channel mode: enabled "${name}", disabled all others`);
206
+ }
207
+
208
+ // ── 子类钩子(默认空实现)──
209
+
210
+ /** 应用渠道默认值,子类覆写以添加平台特有字段 */
211
+ _applyDefaults(channel) {
212
+ const normalized = { ...channel };
213
+ if (normalized.enabled === undefined) {
214
+ normalized.enabled = true;
215
+ } else {
216
+ normalized.enabled = !!normalized.enabled;
217
+ }
218
+ normalized.weight = normalizeNumber(normalized.weight, 1, 100);
219
+ normalized.maxConcurrency = normalizeNumber(normalized.maxConcurrency, 0);
220
+ normalized.gatewaySourceType = normalizeGatewaySourceType(
221
+ normalized.gatewaySourceType,
222
+ this.defaultGatewaySource
223
+ );
224
+ return normalized;
225
+ }
226
+
227
+ /** 唯一性校验,子类覆写(如 Codex 的 providerKey、Gemini 的 name) */
228
+ _validateUniqueness(_channels, _fields, _excludeId) {
229
+ // 默认无校验
230
+ }
231
+
232
+ /** 创建后钩子 */
233
+ _onAfterCreate(_channel, _allChannels) {}
234
+
235
+ /** 更新后钩子 */
236
+ _onAfterUpdate(_oldChannel, _newChannel, _allChannels) {}
237
+
238
+ /** 删除后钩子 */
239
+ _onAfterDelete(_channel, _allChannels) {}
240
+
241
+ /** 将渠道配置写入平台原生设置文件,子类必须覆写 */
242
+ _applyToNativeSettings(_channel) {
243
+ throw new Error(`${this.platform}: _applyToNativeSettings not implemented`);
244
+ }
245
+ }
246
+
247
+ module.exports = BaseChannelService;