@adversity/coding-tool-x 3.1.1 → 3.1.3

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 (69) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/web/assets/Analytics-BIqc8Rin.css +1 -0
  3. package/dist/web/assets/Analytics-D2V09DHH.js +39 -0
  4. package/dist/web/assets/{ConfigTemplates-ZrK_s7ma.js → ConfigTemplates-Bf_11LhH.js} +1 -1
  5. package/dist/web/assets/Home-BRnW4FTS.js +1 -0
  6. package/dist/web/assets/Home-CyCIx4BA.css +1 -0
  7. package/dist/web/assets/{PluginManager-BD7QUZbU.js → PluginManager-B9J32GhW.js} +1 -1
  8. package/dist/web/assets/{ProjectList-DRb1DuHV.js → ProjectList-5a19MWJk.js} +1 -1
  9. package/dist/web/assets/SessionList-CXUr6S7w.css +1 -0
  10. package/dist/web/assets/SessionList-Cxg5bAdT.js +1 -0
  11. package/dist/web/assets/{SkillManager-C1xG5B4Q.js → SkillManager-CVBr0CLi.js} +1 -1
  12. package/dist/web/assets/{Terminal-DksBo_lM.js → Terminal-D2Xe_Q0H.js} +1 -1
  13. package/dist/web/assets/{WorkspaceManager-Burx7XOo.js → WorkspaceManager-C7dwV94C.js} +1 -1
  14. package/dist/web/assets/icons-BxcwoY5F.js +1 -0
  15. package/dist/web/assets/index-BS9RA6SN.js +2 -0
  16. package/dist/web/assets/index-DUNAVDGb.css +1 -0
  17. package/dist/web/assets/naive-ui-BIXcURHZ.js +1 -0
  18. package/dist/web/assets/{vendors-CO3Upi1d.js → vendors-i5CBGnlm.js} +1 -1
  19. package/dist/web/assets/{vue-vendor-DqyWIXEb.js → vue-vendor-PKd8utv_.js} +1 -1
  20. package/dist/web/index.html +6 -6
  21. package/package.json +1 -1
  22. package/src/config/default.js +7 -27
  23. package/src/config/loader.js +6 -3
  24. package/src/config/model-metadata.js +167 -0
  25. package/src/config/model-metadata.json +125 -0
  26. package/src/config/model-pricing.js +23 -93
  27. package/src/server/api/channels.js +16 -39
  28. package/src/server/api/codex-channels.js +15 -43
  29. package/src/server/api/commands.js +0 -77
  30. package/src/server/api/config.js +4 -1
  31. package/src/server/api/gemini-channels.js +16 -40
  32. package/src/server/api/opencode-channels.js +108 -56
  33. package/src/server/api/opencode-proxy.js +42 -33
  34. package/src/server/api/opencode-sessions.js +4 -69
  35. package/src/server/api/sessions.js +11 -68
  36. package/src/server/api/settings.js +138 -0
  37. package/src/server/api/skills.js +0 -44
  38. package/src/server/api/statistics.js +115 -1
  39. package/src/server/codex-proxy-server.js +32 -59
  40. package/src/server/gemini-proxy-server.js +21 -18
  41. package/src/server/index.js +13 -7
  42. package/src/server/opencode-proxy-server.js +1232 -197
  43. package/src/server/proxy-server.js +8 -8
  44. package/src/server/services/codex-sessions.js +105 -6
  45. package/src/server/services/commands-service.js +0 -29
  46. package/src/server/services/config-templates-service.js +38 -28
  47. package/src/server/services/env-checker.js +97 -9
  48. package/src/server/services/env-manager.js +29 -1
  49. package/src/server/services/opencode-channels.js +3 -1
  50. package/src/server/services/opencode-sessions.js +486 -218
  51. package/src/server/services/opencode-settings-manager.js +172 -36
  52. package/src/server/services/plugins-service.js +37 -28
  53. package/src/server/services/pty-manager.js +22 -18
  54. package/src/server/services/response-decoder.js +21 -0
  55. package/src/server/services/skill-service.js +1 -49
  56. package/src/server/services/speed-test.js +40 -3
  57. package/src/server/services/statistics-service.js +238 -1
  58. package/src/server/utils/pricing.js +51 -60
  59. package/src/server/websocket-server.js +24 -5
  60. package/dist/web/assets/Home-B8YfhZ3c.js +0 -1
  61. package/dist/web/assets/Home-Di2qsylF.css +0 -1
  62. package/dist/web/assets/SessionList-BGJWyneI.css +0 -1
  63. package/dist/web/assets/SessionList-lZ0LKzfT.js +0 -1
  64. package/dist/web/assets/icons-kcfLIMBB.js +0 -1
  65. package/dist/web/assets/index-Ufv5rCa5.css +0 -1
  66. package/dist/web/assets/index-lAkrRC3h.js +0 -2
  67. package/dist/web/assets/naive-ui-CSrLusZZ.js +0 -1
  68. package/src/server/api/convert.js +0 -260
  69. package/src/server/services/session-converter.js +0 -577
@@ -8,11 +8,11 @@ const { recordSuccess, recordFailure } = require('./services/channel-health');
8
8
  const { broadcastLog, broadcastSchedulerState } = require('./websocket-server');
9
9
  const { loadConfig } = require('../config/loader');
10
10
  const DEFAULT_CONFIG = require('../config/default');
11
- const { resolvePricing, resolveModelPricing } = require('./utils/pricing');
11
+ const { resolveModelPricing } = require('./utils/pricing');
12
12
  const { recordRequest } = require('./services/statistics-service');
13
13
  const { saveProxyStartTime, clearProxyStartTime, getProxyStartTime, getProxyRuntime } = require('./services/proxy-runtime');
14
+ const { createDecodedStream } = require('./services/response-decoder');
14
15
  const eventBus = require('../plugins/event-bus');
15
- const { CLAUDE_MODEL_PRICING } = require('../config/model-pricing');
16
16
  const { getEffectiveApiKey } = require('./services/channels');
17
17
 
18
18
  let proxyServer = null;
@@ -94,8 +94,7 @@ function redirectModel(originalModel, channel) {
94
94
  * @returns {number} 成本(美元)
95
95
  */
96
96
  function calculateCost(model, tokens) {
97
- const hardcodedPricing = CLAUDE_MODEL_PRICING[model] || {};
98
- const pricing = resolveModelPricing('claude', model, hardcodedPricing, CLAUDE_BASE_PRICING);
97
+ const pricing = resolveModelPricing('claude', model, {}, CLAUDE_BASE_PRICING);
99
98
 
100
99
  const inputRate = typeof pricing.input === 'number' ? pricing.input : CLAUDE_BASE_PRICING.input;
101
100
  const outputRate = typeof pricing.output === 'number' ? pricing.output : CLAUDE_BASE_PRICING.output;
@@ -334,11 +333,12 @@ async function startProxyServer(options = {}) {
334
333
  cacheRead: 0,
335
334
  model: ''
336
335
  };
336
+ const parsedStream = createDecodedStream(proxyRes);
337
337
 
338
- proxyRes.on('data', (chunk) => {
338
+ parsedStream.on('data', (chunk) => {
339
339
  if (isResponseClosed) return;
340
340
 
341
- buffer += chunk.toString();
341
+ buffer += chunk.toString('utf8');
342
342
 
343
343
  const events = buffer.split('\n\n');
344
344
  buffer = events.pop() || '';
@@ -448,9 +448,9 @@ async function startProxyServer(options = {}) {
448
448
  }
449
449
  };
450
450
 
451
- proxyRes.on('end', finalize);
451
+ parsedStream.on('end', finalize);
452
452
 
453
- proxyRes.on('error', (err) => {
453
+ parsedStream.on('error', (err) => {
454
454
  if (err.code !== 'EPIPE' && err.code !== 'ECONNRESET') {
455
455
  console.error('Proxy response error:', err);
456
456
  }
@@ -3,6 +3,15 @@ const path = require('path');
3
3
  const { getCodexDir } = require('./codex-config');
4
4
  const { parseSession, parseSessionMeta, extractSessionMeta, readJSONL } = require('./codex-parser');
5
5
 
6
+ const COUNTS_CACHE_TTL_MS = 30 * 1000;
7
+ const FAST_META_READ_BYTES = 64 * 1024;
8
+ const EMPTY_COUNTS = Object.freeze({ projectCount: 0, sessionCount: 0 });
9
+
10
+ let countsCache = {
11
+ expiresAt: 0,
12
+ value: EMPTY_COUNTS
13
+ };
14
+
6
15
  /**
7
16
  * 获取会话目录
8
17
  */
@@ -437,6 +446,7 @@ function deleteProject(projectName) {
437
446
  console.error('[Codex] Failed to clean session order:', err.message);
438
447
  }
439
448
 
449
+ invalidateProjectAndSessionCountsCache();
440
450
  return { success: true, deletedCount };
441
451
  }
442
452
 
@@ -524,6 +534,7 @@ function deleteSession(sessionId) {
524
534
  // 忽略别名不存在的错误
525
535
  }
526
536
 
537
+ invalidateProjectAndSessionCountsCache();
527
538
  return { success: true };
528
539
  }
529
540
 
@@ -577,6 +588,7 @@ function forkSession(sessionId) {
577
588
  forkRelations[newSessionId] = sessionId;
578
589
  saveForkRelations(forkRelations);
579
590
 
591
+ invalidateProjectAndSessionCountsCache();
580
592
  return {
581
593
  newSessionId,
582
594
  forkedFrom: sessionId,
@@ -628,19 +640,106 @@ function saveProjectOrder(order) {
628
640
  saveClaudeProjectOrder({ projectsDir: getCodexDir() }, order);
629
641
  }
630
642
 
643
+ function invalidateProjectAndSessionCountsCache() {
644
+ countsCache.expiresAt = 0;
645
+ }
646
+
647
+ function extractCodexProjectNameFromMeta(metaPayload = {}) {
648
+ const repoUrl = metaPayload?.git?.repository_url || metaPayload?.git?.repositoryUrl;
649
+ if (typeof repoUrl === 'string' && repoUrl.trim()) {
650
+ const parsedName = repoUrl.split('/').pop();
651
+ if (parsedName) {
652
+ const normalized = parsedName.replace(/\.git$/i, '').trim();
653
+ if (normalized) return normalized;
654
+ }
655
+ }
656
+
657
+ const cwd = metaPayload?.cwd;
658
+ if (typeof cwd === 'string' && cwd.trim()) {
659
+ return path.basename(cwd.trim());
660
+ }
661
+
662
+ return '';
663
+ }
664
+
665
+ function readSessionMetaPayloadFast(filePath) {
666
+ let fd;
667
+ try {
668
+ fd = fs.openSync(filePath, 'r');
669
+ const buffer = Buffer.alloc(FAST_META_READ_BYTES);
670
+ const bytesRead = fs.readSync(fd, buffer, 0, FAST_META_READ_BYTES, 0);
671
+ if (bytesRead <= 0) return null;
672
+
673
+ const chunk = buffer.toString('utf8', 0, bytesRead);
674
+ const lines = chunk.split('\n');
675
+
676
+ for (const line of lines) {
677
+ const trimmed = line.trim();
678
+ if (!trimmed) continue;
679
+ let parsed;
680
+ try {
681
+ parsed = JSON.parse(trimmed);
682
+ } catch (err) {
683
+ continue;
684
+ }
685
+ if (parsed?.type === 'session_meta' && parsed?.payload && typeof parsed.payload === 'object') {
686
+ return parsed.payload;
687
+ }
688
+ }
689
+ } catch (err) {
690
+ return null;
691
+ } finally {
692
+ if (fd !== undefined) {
693
+ try {
694
+ fs.closeSync(fd);
695
+ } catch (err) {
696
+ // ignore close errors
697
+ }
698
+ }
699
+ }
700
+
701
+ return null;
702
+ }
703
+
704
+ function calculateProjectAndSessionCounts() {
705
+ const sessions = scanSessionFiles();
706
+ if (sessions.length === 0) {
707
+ return EMPTY_COUNTS;
708
+ }
709
+
710
+ const projectNames = new Set();
711
+ sessions.forEach((session) => {
712
+ const payload = readSessionMetaPayloadFast(session.filePath);
713
+ const projectName = extractCodexProjectNameFromMeta(payload || {});
714
+ if (projectName) {
715
+ projectNames.add(projectName);
716
+ }
717
+ });
718
+
719
+ return {
720
+ projectCount: projectNames.size,
721
+ sessionCount: sessions.length
722
+ };
723
+ }
724
+
631
725
  /**
632
726
  * 获取 Codex 项目与会话数量(用于仪表盘轻量统计)
633
727
  */
634
728
  function getProjectAndSessionCounts() {
729
+ const now = Date.now();
730
+ if (countsCache.expiresAt > now) {
731
+ return countsCache.value;
732
+ }
733
+
635
734
  try {
636
- const projects = getProjects();
637
- const sessions = scanSessionFiles();
638
- return {
639
- projectCount: projects.length,
640
- sessionCount: sessions.length
735
+ const counts = calculateProjectAndSessionCounts();
736
+ countsCache = {
737
+ value: counts,
738
+ expiresAt: now + COUNTS_CACHE_TTL_MS
641
739
  };
740
+ return counts;
642
741
  } catch (err) {
643
- return { projectCount: 0, sessionCount: 0 };
742
+ return countsCache.value || EMPTY_COUNTS;
644
743
  }
645
744
  }
646
745
 
@@ -12,9 +12,6 @@ const { RepoScannerBase } = require('./repo-scanner-base');
12
12
  const { NATIVE_PATHS } = require('../../config/paths');
13
13
  const {
14
14
  parseCommandContent,
15
- detectCommandFormat,
16
- convertCommandToCodex,
17
- convertCommandToClaude,
18
15
  parseFrontmatter
19
16
  } = require('./format-converter');
20
17
 
@@ -569,32 +566,6 @@ class CommandsService {
569
566
 
570
567
  // ==================== 格式转换 ====================
571
568
 
572
- /**
573
- * 转换命令格式
574
- * @param {string} content - 命令内容
575
- * @param {string} targetFormat - 目标格式 ('claude' | 'codex')
576
- */
577
- convertCommandFormat(content, targetFormat) {
578
- const sourceFormat = detectCommandFormat(content);
579
-
580
- if (sourceFormat === targetFormat) {
581
- return { content, warnings: [], format: targetFormat };
582
- }
583
-
584
- if (targetFormat === 'codex') {
585
- return convertCommandToCodex(content);
586
- } else {
587
- return convertCommandToClaude(content);
588
- }
589
- }
590
-
591
- /**
592
- * 检测命令格式
593
- * @param {string} content - 命令内容
594
- */
595
- detectFormat(content) {
596
- return detectCommandFormat(content);
597
- }
598
569
  }
599
570
 
600
571
  module.exports = {
@@ -28,6 +28,7 @@ const BUILTIN_TEMPLATES = [
28
28
  id: 'full-stack',
29
29
  name: '全栈开发',
30
30
  description: '前后端全栈开发配置,包含代码编辑、文档查询、版本控制等常用工具',
31
+ cliType: 'claude',
31
32
  // 兼容旧字段
32
33
  claudeMd: { enabled: false, content: '' },
33
34
  // 新的多 AI 配置
@@ -159,6 +160,7 @@ You are an experienced full-stack developer focused on delivering high-quality c
159
160
  id: 'architecture',
160
161
  name: '方案设计',
161
162
  description: '专注于技术方案设计、架构评审、系统设计,适合需求分析和技术决策场景',
163
+ cliType: 'claude',
162
164
  claudeMd: { enabled: false, content: '' },
163
165
  aiConfigs: {
164
166
  claude: {
@@ -308,6 +310,7 @@ You are a senior technical architect focused on system design and technical plan
308
310
  id: 'code-review',
309
311
  name: '代码审查',
310
312
  description: '专注于代码审查、质量评估、安全检查,适合 PR Review 和代码质量改进',
313
+ cliType: 'claude',
311
314
  claudeMd: { enabled: false, content: '' },
312
315
  aiConfigs: {
313
316
  claude: {
@@ -467,6 +470,7 @@ For each issue:
467
470
  id: 'minimal',
468
471
  name: '最小配置',
469
472
  description: '纯净环境,不添加任何额外配置,适合已有完善配置的项目',
473
+ cliType: 'claude',
470
474
  claudeMd: { enabled: false, content: '' },
471
475
  aiConfigs: {
472
476
  claude: { enabled: false, content: '' },
@@ -619,6 +623,7 @@ function createCustomTemplate(template) {
619
623
  id,
620
624
  name: template.name,
621
625
  description: template.description || '',
626
+ cliType: template.cliType || 'claude',
622
627
  claudeMd: template.claudeMd || { enabled: false, content: '' },
623
628
  aiConfigs: normalizeAiConfigs(template.aiConfigs, template.claudeMd),
624
629
  skills: template.skills || [],
@@ -651,6 +656,7 @@ function updateCustomTemplate(id, updates) {
651
656
  custom[index] = {
652
657
  ...custom[index],
653
658
  ...updates,
659
+ cliType: updates.cliType !== undefined ? updates.cliType : (custom[index].cliType || 'claude'),
654
660
  aiConfigs: normalizeAiConfigs(updates.aiConfigs || custom[index].aiConfigs, updates.claudeMd || custom[index].claudeMd),
655
661
  id: custom[index].id, // 保持 ID 不变
656
662
  isBuiltin: false,
@@ -971,12 +977,15 @@ function applyTemplateToProject(targetDir, templateId, options = {}) {
971
977
  }
972
978
  }
973
979
 
974
- // 2. 写入 Agents
980
+ // 2. 写入 Agents(根据选中的 AI 类型决定写入哪些目录)
975
981
  if (template.agents?.length > 0) {
976
- const agentTargets = [
977
- { baseDir: path.join(targetDir, '.claude', 'agents'), prefix: '.claude/agents' },
978
- { baseDir: path.join(targetDir, '.opencode', 'agents'), prefix: '.opencode/agents' }
979
- ];
982
+ const agentTargets = [];
983
+ if (aiConfigTypes.includes('claude')) {
984
+ agentTargets.push({ baseDir: path.join(targetDir, '.claude', 'agents'), prefix: '.claude/agents' });
985
+ }
986
+ if (aiConfigTypes.includes('opencode')) {
987
+ agentTargets.push({ baseDir: path.join(targetDir, '.opencode', 'agents'), prefix: '.opencode/agents' });
988
+ }
980
989
 
981
990
  for (const target of agentTargets) {
982
991
  ensureDir(target.baseDir);
@@ -994,12 +1003,15 @@ function applyTemplateToProject(targetDir, templateId, options = {}) {
994
1003
  }
995
1004
  }
996
1005
 
997
- // 3. 写入 Commands
1006
+ // 3. 写入 Commands(根据选中的 AI 类型决定写入哪些目录)
998
1007
  if (template.commands?.length > 0) {
999
- const commandTargets = [
1000
- { baseDir: path.join(targetDir, '.claude', 'commands'), prefix: '.claude/commands' },
1001
- { baseDir: path.join(targetDir, '.opencode', 'commands'), prefix: '.opencode/commands' }
1002
- ];
1008
+ const commandTargets = [];
1009
+ if (aiConfigTypes.includes('claude')) {
1010
+ commandTargets.push({ baseDir: path.join(targetDir, '.claude', 'commands'), prefix: '.claude/commands' });
1011
+ }
1012
+ if (aiConfigTypes.includes('opencode')) {
1013
+ commandTargets.push({ baseDir: path.join(targetDir, '.opencode', 'commands'), prefix: '.opencode/commands' });
1014
+ }
1003
1015
 
1004
1016
  for (const target of commandTargets) {
1005
1017
  ensureDir(target.baseDir);
@@ -1189,16 +1201,16 @@ function previewTemplateApplication(targetDir, templateId, options = {}) {
1189
1201
  preview.summary.skills = template.skills.length;
1190
1202
  }
1191
1203
 
1192
- // 检查 Agents
1204
+ // 检查 Agents(根据选中的 AI 类型决定预览哪些目录)
1193
1205
  if (template.agents?.length > 0) {
1206
+ const agentPrefixes = [];
1207
+ if (aiConfigTypes.includes('claude')) agentPrefixes.push('.claude/agents');
1208
+ if (aiConfigTypes.includes('opencode')) agentPrefixes.push('.opencode/agents');
1209
+
1194
1210
  for (const agent of template.agents) {
1195
1211
  const fileName = agent.fileName || agent.name.toLowerCase().replace(/\s+/g, '-');
1196
- const relativePaths = [
1197
- `.claude/agents/${fileName}.md`,
1198
- `.opencode/agents/${fileName}.md`
1199
- ];
1200
-
1201
- for (const relativePath of relativePaths) {
1212
+ for (const prefix of agentPrefixes) {
1213
+ const relativePath = `${prefix}/${fileName}.md`;
1202
1214
  const fullPath = path.join(targetDir, relativePath);
1203
1215
  if (fs.existsSync(fullPath)) {
1204
1216
  preview.willOverwrite.push(relativePath);
@@ -1210,19 +1222,17 @@ function previewTemplateApplication(targetDir, templateId, options = {}) {
1210
1222
  }
1211
1223
  }
1212
1224
 
1213
- // 检查 Commands
1225
+ // 检查 Commands(根据选中的 AI 类型决定预览哪些目录)
1214
1226
  if (template.commands?.length > 0) {
1227
+ const commandPrefixes = [];
1228
+ if (aiConfigTypes.includes('claude')) commandPrefixes.push('.claude/commands');
1229
+ if (aiConfigTypes.includes('opencode')) commandPrefixes.push('.opencode/commands');
1230
+
1215
1231
  for (const command of template.commands) {
1216
- const relativePaths = [
1217
- command.namespace
1218
- ? `.claude/commands/${command.namespace}/${command.name}.md`
1219
- : `.claude/commands/${command.name}.md`,
1220
- command.namespace
1221
- ? `.opencode/commands/${command.namespace}/${command.name}.md`
1222
- : `.opencode/commands/${command.name}.md`
1223
- ];
1224
-
1225
- for (const relativePath of relativePaths) {
1232
+ for (const prefix of commandPrefixes) {
1233
+ const relativePath = command.namespace
1234
+ ? `${prefix}/${command.namespace}/${command.name}.md`
1235
+ : `${prefix}/${command.name}.md`;
1226
1236
  const fullPath = path.join(targetDir, relativePath);
1227
1237
  if (fs.existsSync(fullPath)) {
1228
1238
  preview.willOverwrite.push(relativePath);
@@ -8,6 +8,7 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const os = require('os');
11
+ const crypto = require('crypto');
11
12
 
12
13
  // 各平台需要检测的环境变量关键词
13
14
  const PLATFORM_KEYWORDS = {
@@ -47,8 +48,11 @@ const SHELL_CONFIG_FILES = process.platform === 'win32'
47
48
  ? [
48
49
  '.bashrc',
49
50
  '.bash_profile',
51
+ '.bash_login',
50
52
  '.zshrc',
53
+ '.zshenv',
51
54
  '.zprofile',
55
+ '.zlogin',
52
56
  '.profile',
53
57
  path.join('Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'),
54
58
  path.join('Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1')
@@ -56,8 +60,11 @@ const SHELL_CONFIG_FILES = process.platform === 'win32'
56
60
  : [
57
61
  '.bashrc',
58
62
  '.bash_profile',
63
+ '.bash_login',
59
64
  '.zshrc',
65
+ '.zshenv',
60
66
  '.zprofile',
67
+ '.zlogin',
61
68
  '.profile'
62
69
  ];
63
70
 
@@ -86,8 +93,10 @@ function checkEnvConflicts(platform = null) {
86
93
  // 3. 检测系统配置文件
87
94
  conflicts.push(...checkSystemConfigs(keywords));
88
95
 
89
- // 去重(同一变量可能在多处定义)
90
- return deduplicateConflicts(conflicts);
96
+ // 去重 + 只保留“真实冲突”(同名变量在多个来源且值不一致)
97
+ const deduplicated = deduplicateConflicts(conflicts);
98
+ const realConflicts = filterRealConflicts(deduplicated);
99
+ return sanitizeConflicts(realConflicts);
91
100
  }
92
101
 
93
102
  /**
@@ -108,10 +117,15 @@ function checkProcessEnv(keywords) {
108
117
  const conflicts = [];
109
118
 
110
119
  for (const [key, value] of Object.entries(process.env)) {
120
+ if (!hasNonEmptyValue(value)) {
121
+ continue;
122
+ }
123
+
111
124
  if (matchesKeywords(key, keywords)) {
112
125
  conflicts.push({
113
126
  varName: key,
114
127
  varValue: maskSensitiveValue(value),
128
+ valueFingerprint: hashValue(value),
115
129
  sourceType: 'process',
116
130
  sourcePath: 'Process Environment',
117
131
  platform: detectPlatform(key)
@@ -183,11 +197,18 @@ function parseConfigFile(filePath, keywords) {
183
197
 
184
198
  if (matched) {
185
199
  const [, varName, varValue] = matched;
200
+ const normalizedValue = cleanValue(varValue);
201
+
202
+ // 空值通常是占位或已清理状态,不再视为冲突
203
+ if (!hasNonEmptyValue(normalizedValue)) {
204
+ continue;
205
+ }
186
206
 
187
207
  if (matchesKeywords(varName, keywords)) {
188
208
  conflicts.push({
189
209
  varName,
190
- varValue: maskSensitiveValue(cleanValue(varValue)),
210
+ varValue: maskSensitiveValue(normalizedValue),
211
+ valueFingerprint: hashValue(normalizedValue),
191
212
  sourceType: 'file',
192
213
  sourcePath: `${filePath}:${i + 1}`,
193
214
  filePath,
@@ -219,17 +240,17 @@ function parseConfigFile(filePath, keywords) {
219
240
  function matchesKeywords(varName, keywords) {
220
241
  const upperName = varName.toUpperCase();
221
242
 
222
- // 首先检查是否包含平台关键词
223
- const hasKeyword = keywords.some(keyword => upperName.includes(keyword));
224
- if (!hasKeyword) {
225
- return false;
226
- }
227
-
228
243
  // 检查是否精确匹配已知敏感变量
229
244
  if (EXACT_SENSITIVE_VARS.includes(upperName)) {
230
245
  return true;
231
246
  }
232
247
 
248
+ // 首先检查是否命中平台关键词(按 token 匹配,避免 OPENAI2 误判为 OPENAI)
249
+ const hasKeyword = keywords.some(keyword => matchesKeywordToken(upperName, keyword));
250
+ if (!hasKeyword) {
251
+ return false;
252
+ }
253
+
233
254
  // 检查是否以敏感后缀结尾
234
255
  const hasSensitiveSuffix = SENSITIVE_PATTERNS.some(suffix =>
235
256
  upperName.endsWith(suffix)
@@ -238,6 +259,16 @@ function matchesKeywords(varName, keywords) {
238
259
  return hasSensitiveSuffix;
239
260
  }
240
261
 
262
+ function matchesKeywordToken(varName, keyword) {
263
+ const token = String(keyword || '').trim().toUpperCase();
264
+ if (!token) return false;
265
+
266
+ return varName === token ||
267
+ varName.startsWith(`${token}_`) ||
268
+ varName.endsWith(`_${token}`) ||
269
+ varName.includes(`_${token}_`);
270
+ }
271
+
241
272
  /**
242
273
  * 检测变量属于哪个平台
243
274
  */
@@ -266,6 +297,13 @@ function cleanValue(value) {
266
297
  return cleaned;
267
298
  }
268
299
 
300
+ /**
301
+ * 判断值是否为非空
302
+ */
303
+ function hasNonEmptyValue(value) {
304
+ return typeof value === 'string' && value.trim() !== '';
305
+ }
306
+
269
307
  /**
270
308
  * 遮蔽敏感值
271
309
  */
@@ -275,6 +313,56 @@ function maskSensitiveValue(value) {
275
313
  return value.substring(0, 4) + '****' + value.substring(value.length - 4);
276
314
  }
277
315
 
316
+ function hashValue(value) {
317
+ return crypto
318
+ .createHash('sha256')
319
+ .update(String(value ?? ''), 'utf8')
320
+ .digest('hex');
321
+ }
322
+
323
+ function filterRealConflicts(conflicts) {
324
+ const grouped = new Map();
325
+
326
+ for (const conflict of conflicts) {
327
+ const key = String(conflict.varName || '').toUpperCase();
328
+ if (!grouped.has(key)) {
329
+ grouped.set(key, []);
330
+ }
331
+ grouped.get(key).push(conflict);
332
+ }
333
+
334
+ const results = [];
335
+ for (const group of grouped.values()) {
336
+ if (group.length < 2) {
337
+ continue;
338
+ }
339
+
340
+ const sourceCount = new Set(group.map(item => item.sourcePath)).size;
341
+ if (sourceCount < 2) {
342
+ continue;
343
+ }
344
+
345
+ const valueVariants = new Set(
346
+ group
347
+ .map(item => item.valueFingerprint)
348
+ .filter(Boolean)
349
+ );
350
+
351
+ // 同名变量在多个来源但值一致,不算冲突
352
+ if (valueVariants.size <= 1) {
353
+ continue;
354
+ }
355
+
356
+ results.push(...group);
357
+ }
358
+
359
+ return results;
360
+ }
361
+
362
+ function sanitizeConflicts(conflicts) {
363
+ return conflicts.map(({ valueFingerprint, ...rest }) => rest);
364
+ }
365
+
278
366
  /**
279
367
  * 去重冲突列表
280
368
  */
@@ -124,11 +124,15 @@ function deleteEnvVars(conflicts) {
124
124
  }
125
125
  }
126
126
 
127
+ // 同步清理当前服务进程中的同名变量,避免“删除后立即复检仍提示冲突”
128
+ const clearedProcessVars = clearProcessEnvVars(conflicts);
129
+
127
130
  return {
128
131
  backupPath: backupInfo.backupPath,
129
132
  timestamp: backupInfo.timestamp,
130
133
  results,
131
- processConflictsSkipped: processConflicts.length
134
+ processConflictsSkipped: processConflicts.length,
135
+ clearedProcessVars
132
136
  };
133
137
  }
134
138
 
@@ -220,6 +224,30 @@ function groupByFile(conflicts) {
220
224
  return groups;
221
225
  }
222
226
 
227
+ /**
228
+ * 清理当前 Node 进程中的环境变量
229
+ */
230
+ function clearProcessEnvVars(conflicts) {
231
+ const names = new Set();
232
+
233
+ for (const conflict of conflicts || []) {
234
+ const varName = String(conflict?.varName || '').trim();
235
+ if (varName) {
236
+ names.add(varName);
237
+ }
238
+ }
239
+
240
+ const cleared = [];
241
+ for (const varName of names) {
242
+ if (Object.prototype.hasOwnProperty.call(process.env, varName)) {
243
+ delete process.env[varName];
244
+ cleared.push(varName);
245
+ }
246
+ }
247
+
248
+ return cleared;
249
+ }
250
+
223
251
  /**
224
252
  * 从文件中移除环境变量
225
253
  */
@@ -46,7 +46,8 @@ function loadChannels() {
46
46
  modelRedirects: ch.modelRedirects || [],
47
47
  speedTestModel: ch.speedTestModel || null,
48
48
  wireApi: ch.wireApi || 'openai', // OpenCode 默认使用 OpenAI 兼容格式
49
- gatewaySourceType: normalizeGatewaySourceType(ch.gatewaySourceType)
49
+ gatewaySourceType: normalizeGatewaySourceType(ch.gatewaySourceType),
50
+ allowedModels: ch.allowedModels || []
50
51
  };
51
52
  normalized.providerKey = deriveProviderKey(normalized);
52
53
  return normalized;
@@ -101,6 +102,7 @@ function createChannel(name, baseUrl, apiKey, extraConfig = {}) {
101
102
  providerKey: extraConfig.providerKey || null,
102
103
  presetId: extraConfig.presetId || null,
103
104
  websiteUrl: extraConfig.websiteUrl || '',
105
+ allowedModels: extraConfig.allowedModels || [],
104
106
  createdAt: Date.now(),
105
107
  updatedAt: Date.now()
106
108
  };