coding-tool-x 3.6.7 → 3.6.9

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 (54) hide show
  1. package/dist/web/assets/{Analytics-CKycBI0G.js → Analytics-BuBlsbcI.js} +1 -1
  2. package/dist/web/assets/{ConfigTemplates-CACLQb8e.js → ConfigTemplates-DQczxuOX.js} +1 -1
  3. package/dist/web/assets/Home-BvAzzWnB.js +1 -0
  4. package/dist/web/assets/Home-CIua-b-o.css +1 -0
  5. package/dist/web/assets/{PluginManager-CQJl_uso.js → PluginManager-C8gAlaps.js} +1 -1
  6. package/dist/web/assets/{ProjectList-DSkvQFEP.js → ProjectList-nqgi6_cR.js} +1 -1
  7. package/dist/web/assets/{SessionList-Czjs1GN1.js → SessionList-D8AxMYy6.js} +1 -1
  8. package/dist/web/assets/{SkillManager-Cg1xZ5Cv.js → SkillManager-BDbNbNkK.js} +1 -1
  9. package/dist/web/assets/{WorkspaceManager-COjYeKvN.js → WorkspaceManager-DGGaGW2c.js} +1 -1
  10. package/dist/web/assets/index-Dsh4Q5Cg.css +1 -0
  11. package/dist/web/assets/index-y-eEayxR.js +2 -0
  12. package/dist/web/assets/{naive-ui-DM897mIP.js → naive-ui-kcWDeJwl.js} +1 -1
  13. package/dist/web/index.html +3 -3
  14. package/package.json +1 -1
  15. package/src/commands/stats.js +1 -1
  16. package/src/config/channel-preset-websites.js +2 -1
  17. package/src/config/paths.js +8 -0
  18. package/src/server/api/agents.js +16 -54
  19. package/src/server/api/claude-hooks.js +1 -1
  20. package/src/server/api/claude-statistics.js +57 -0
  21. package/src/server/api/commands.js +16 -54
  22. package/src/server/api/dashboard.js +2 -2
  23. package/src/server/api/gemini-channels.js +2 -0
  24. package/src/server/api/hooks.js +4 -1
  25. package/src/server/api/plugins.js +46 -69
  26. package/src/server/api/skills.js +21 -77
  27. package/src/server/api/validation-errors.js +34 -0
  28. package/src/server/gemini-proxy-server.js +205 -21
  29. package/src/server/index.js +1 -0
  30. package/src/server/services/agents-service.js +166 -75
  31. package/src/server/services/base/base-channel-service.js +15 -0
  32. package/src/server/services/base/response-usage-parser.js +44 -0
  33. package/src/server/services/channel-balance.js +86 -1
  34. package/src/server/services/claude-statistics-service.js +139 -0
  35. package/src/server/services/commands-service.js +203 -121
  36. package/src/server/services/config-artifact-paths.js +116 -0
  37. package/src/server/services/config-export-service.js +7 -6
  38. package/src/server/services/config-registry-service.js +2 -2
  39. package/src/server/services/config-sync-manager.js +45 -11
  40. package/src/server/services/config-templates-service.js +21 -19
  41. package/src/server/services/format-converter.js +42 -4
  42. package/src/server/services/gemini-channels.js +30 -1
  43. package/src/server/services/gemini-sessions.js +347 -61
  44. package/src/server/services/mcp-client.js +236 -49
  45. package/src/server/services/mcp-service.js +51 -64
  46. package/src/server/services/notification-hooks.js +659 -88
  47. package/src/server/services/opencode-channels.js +15 -0
  48. package/src/server/services/plugins-service.js +1137 -86
  49. package/src/server/services/repo-scanner-base.js +6 -29
  50. package/src/server/services/skill-service.js +198 -65
  51. package/dist/web/assets/Home-BeGkC4bb.css +0 -1
  52. package/dist/web/assets/Home-Bk6sP5Ed.js +0 -1
  53. package/dist/web/assets/index-0isNzXNY.css +0 -1
  54. package/dist/web/assets/index-fWgvlZQx.js +0 -2
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Agents 服务
3
3
  *
4
- * 管理 Claude/OpenCode 自定义代理的 CRUD 操作
4
+ * 管理 Claude/Codex/Gemini/OpenCode 自定义代理的 CRUD 操作
5
5
  * 支持从 GitHub 仓库扫描和安装代理
6
6
  */
7
7
 
@@ -13,15 +13,21 @@ const tomlStringify = require('@iarna/toml').stringify;
13
13
  const { RepoScannerBase } = require('./repo-scanner-base');
14
14
  const { NATIVE_PATHS } = require('../../config/paths');
15
15
  const { resolvePreferredHomeDir } = require('../../utils/home-dir');
16
+ const {
17
+ normalizeSafeFileStem,
18
+ normalizeSafeRelativePath,
19
+ resolveInsideRoot
20
+ } = require('./config-artifact-paths');
16
21
 
17
22
  // 默认仓库源
18
23
  const DEFAULT_REPOS = [];
19
- const SUPPORTED_PLATFORMS = ['claude', 'codex', 'opencode'];
24
+ const SUPPORTED_PLATFORMS = ['claude', 'codex', 'gemini', 'opencode'];
20
25
  const OPENCODE_CONFIG_DIR = NATIVE_PATHS.opencode.config;
21
26
  const CODEX_CONFIG_PATH = NATIVE_PATHS.codex.config;
22
27
  const HOME_DIR = resolvePreferredHomeDir(process.platform, process.env, os.homedir());
23
28
  const CODEX_AGENTS_DIR = path.join(path.dirname(CODEX_CONFIG_PATH), 'agents');
24
29
  const CLAUDE_AGENTS_DIR = path.join(path.dirname(NATIVE_PATHS.claude.settings), 'agents');
30
+ const GEMINI_AGENTS_DIR = path.join(NATIVE_PATHS.gemini.dir, 'agents');
25
31
  const CODEX_CONFIG_MODES = new Set(['none', 'managed', 'custom']);
26
32
 
27
33
  const PLATFORM_CONFIG = {
@@ -47,6 +53,11 @@ const PLATFORM_CONFIG = {
47
53
  userAgentsDir: CODEX_AGENTS_DIR,
48
54
  projectAgentsDir: () => null,
49
55
  repoType: 'agents'
56
+ },
57
+ gemini: {
58
+ userAgentsDir: GEMINI_AGENTS_DIR,
59
+ projectAgentsDir: (projectPath) => path.join(projectPath, '.gemini', 'agents'),
60
+ repoType: 'gemini-agents'
50
61
  }
51
62
  };
52
63
 
@@ -59,39 +70,29 @@ function normalizePlatform(platform) {
59
70
  }
60
71
 
61
72
  function assertSafeAgentFileName(fileName) {
62
- if (typeof fileName !== 'string') {
63
- throw new Error('代理文件名必须是字符串');
64
- }
65
-
66
- const normalized = fileName.trim();
67
- if (!normalized) {
68
- throw new Error('代理文件名不能为空');
69
- }
70
-
71
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(normalized) || normalized.includes('..')) {
73
+ try {
74
+ return normalizeSafeFileStem(fileName, 'agent file name', {
75
+ allowDots: true,
76
+ pattern: /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
77
+ });
78
+ } catch {
72
79
  throw new Error('代理文件名只能包含字母、数字、点号、横杠和下划线,且不能包含连续点');
73
80
  }
74
81
  }
75
82
 
76
83
  function assertSafeRepoPath(repoPath) {
77
- if (typeof repoPath !== 'string' || !repoPath.trim()) {
78
- throw new Error('代理仓库路径不能为空');
79
- }
80
-
81
- const raw = repoPath.replace(/\\/g, '/').trim();
82
- const normalized = path.posix.normalize(raw).replace(/^(\.\/)+/, '');
83
- if (!normalized ||
84
- normalized === '.' ||
85
- normalized === '..' ||
86
- normalized.startsWith('../') ||
87
- normalized.includes('/../') ||
88
- path.posix.isAbsolute(normalized)) {
84
+ let normalized = '';
85
+ try {
86
+ normalized = normalizeSafeRelativePath(repoPath, 'agent repo path');
87
+ } catch {
89
88
  throw new Error('代理仓库路径不合法');
90
89
  }
91
90
 
92
91
  if (!normalized.endsWith('.md')) {
93
92
  throw new Error('代理仓库路径必须是 .md 文件');
94
93
  }
94
+
95
+ return normalized;
95
96
  }
96
97
 
97
98
  function assertSafeProjectPath(projectPath) {
@@ -158,7 +159,8 @@ function isPlainObject(value) {
158
159
  }
159
160
 
160
161
  function getCodexManagedAgentConfigPath(fileName) {
161
- return path.join(CODEX_AGENTS_DIR, `${fileName}.toml`);
162
+ const safeFileName = assertSafeAgentFileName(fileName);
163
+ return resolveInsideRoot(CODEX_AGENTS_DIR, `${safeFileName}.toml`, 'Codex agent config path');
162
164
  }
163
165
 
164
166
  function normalizeCodexConfigPath(configPath) {
@@ -495,8 +497,13 @@ class AgentsRepoScanner extends RepoScannerBase {
495
497
  * 检查代理是否已安装
496
498
  */
497
499
  isInstalled(fileName) {
498
- const fullPath = path.join(this.installDir, `${fileName}.md`);
499
- return fs.existsSync(fullPath);
500
+ try {
501
+ const safeFileName = assertSafeAgentFileName(fileName);
502
+ const fullPath = resolveInsideRoot(this.installDir, `${safeFileName}.md`, 'Agent path');
503
+ return fs.existsSync(fullPath);
504
+ } catch {
505
+ return false;
506
+ }
500
507
  }
501
508
 
502
509
  /**
@@ -510,8 +517,8 @@ class AgentsRepoScanner extends RepoScannerBase {
510
517
  * 安装代理
511
518
  */
512
519
  async installAgent(item) {
513
- assertSafeAgentFileName(item?.fileName);
514
- assertSafeRepoPath(item?.repoPath);
520
+ const safeFileName = assertSafeAgentFileName(item?.fileName);
521
+ const safeRepoPath = assertSafeRepoPath(item?.repoPath);
515
522
 
516
523
  const repo = {
517
524
  owner: item.repoOwner,
@@ -520,7 +527,7 @@ class AgentsRepoScanner extends RepoScannerBase {
520
527
  };
521
528
 
522
529
  // 代理安装到根目录,使用文件名
523
- return this.installFromRepo(item.repoPath, repo, `${item.fileName}.md`);
530
+ return this.installFromRepo(safeRepoPath, repo, `${safeFileName}.md`);
524
531
  }
525
532
  }
526
533
 
@@ -551,6 +558,22 @@ class AgentsService {
551
558
  return this.projectAgentsDir(safeProjectPath);
552
559
  }
553
560
 
561
+ _getBaseDir(scope, projectPath = null) {
562
+ return scope === 'user'
563
+ ? this.userAgentsDir
564
+ : this.getProjectAgentsDir(projectPath);
565
+ }
566
+
567
+ _resolveAgentFilePath(baseDir, fileName) {
568
+ if (!baseDir) {
569
+ throw new Error('项目路径不能为空');
570
+ }
571
+ const safeFileName = assertSafeAgentFileName(fileName);
572
+ const relativePath = `${safeFileName}.md`;
573
+ const fullPath = resolveInsideRoot(baseDir, relativePath, 'Agent path');
574
+ return { safeFileName, relativePath, fullPath };
575
+ }
576
+
554
577
  /**
555
578
  * 获取所有代理列表
556
579
  * @param {string} projectPath - 项目路径(可选,用于获取项目级代理)
@@ -641,17 +664,14 @@ class AgentsService {
641
664
  * 获取单个代理详情
642
665
  */
643
666
  getAgent(fileName, scope, projectPath = null) {
644
- assertSafeAgentFileName(fileName);
667
+ const safeFileName = assertSafeAgentFileName(fileName);
645
668
 
646
669
  if (this.platform === 'codex') {
647
- return this.getCodexAgent(fileName, scope);
670
+ return this.getCodexAgent(safeFileName, scope);
648
671
  }
649
672
 
650
- const baseDir = scope === 'user'
651
- ? this.userAgentsDir
652
- : this.getProjectAgentsDir(projectPath);
653
-
654
- const filePath = path.join(baseDir, `${fileName}.md`);
673
+ const baseDir = this._getBaseDir(scope, projectPath);
674
+ const { relativePath, fullPath: filePath } = this._resolveAgentFilePath(baseDir, safeFileName);
655
675
 
656
676
  if (!fs.existsSync(filePath)) {
657
677
  return null;
@@ -661,10 +681,10 @@ class AgentsService {
661
681
  const { frontmatter, body } = parseFrontmatter(content);
662
682
 
663
683
  return {
664
- name: frontmatter.name || fileName,
665
- fileName,
684
+ name: frontmatter.name || safeFileName,
685
+ fileName: safeFileName,
666
686
  scope,
667
- path: `${fileName}.md`,
687
+ path: relativePath,
668
688
  fullPath: filePath,
669
689
  description: frontmatter.description || '',
670
690
  tools: frontmatter.tools || '',
@@ -681,10 +701,10 @@ class AgentsService {
681
701
  * 创建代理
682
702
  */
683
703
  createAgent({ fileName, scope, projectPath, name, description, tools, model, permissionMode, skills, systemPrompt, configMode, configFile, configContent }) {
684
- assertSafeAgentFileName(fileName);
704
+ const safeFileName = assertSafeAgentFileName(fileName);
685
705
 
686
706
  if (this.platform === 'codex') {
687
- return this.createCodexAgent({ fileName, scope, description, model, configMode, configFile, configContent });
707
+ return this.createCodexAgent({ fileName: safeFileName, scope, description, model, configMode, configFile, configContent });
688
708
  }
689
709
 
690
710
  if (this.platform === 'claude' && (!name || !name.trim())) {
@@ -695,21 +715,18 @@ class AgentsService {
695
715
  throw new Error('代理描述不能为空');
696
716
  }
697
717
 
698
- const baseDir = scope === 'user'
699
- ? this.userAgentsDir
700
- : this.getProjectAgentsDir(projectPath);
718
+ const baseDir = this._getBaseDir(scope, projectPath);
719
+ const { fullPath: filePath } = this._resolveAgentFilePath(baseDir, safeFileName);
701
720
 
702
721
  ensureDir(baseDir);
703
722
 
704
- const filePath = path.join(baseDir, `${fileName}.md`);
705
-
706
723
  // 检查是否已存在
707
724
  if (fs.existsSync(filePath)) {
708
- throw new Error(`代理 "${fileName}" 已存在`);
725
+ throw new Error(`代理 "${safeFileName}" 已存在`);
709
726
  }
710
727
 
711
728
  // 生成文件内容
712
- const frontmatterData = { name: (name || fileName), description };
729
+ const frontmatterData = { name: (name || safeFileName), description };
713
730
  if (tools) frontmatterData.tools = tools;
714
731
  if (model) frontmatterData.model = model;
715
732
  if (permissionMode) frontmatterData.permissionMode = permissionMode;
@@ -719,32 +736,29 @@ class AgentsService {
719
736
 
720
737
  fs.writeFileSync(filePath, content, 'utf-8');
721
738
 
722
- return this.getAgent(fileName, scope, projectPath);
739
+ return this.getAgent(safeFileName, scope, projectPath);
723
740
  }
724
741
 
725
742
  /**
726
743
  * 更新代理
727
744
  */
728
745
  updateAgent({ fileName, scope, projectPath, name, description, tools, model, permissionMode, skills, systemPrompt, configMode, configFile, configContent }) {
729
- assertSafeAgentFileName(fileName);
746
+ const safeFileName = assertSafeAgentFileName(fileName);
730
747
 
731
748
  if (this.platform === 'codex') {
732
- return this.updateCodexAgent({ fileName, scope, description, model, configMode, configFile, configContent });
749
+ return this.updateCodexAgent({ fileName: safeFileName, scope, description, model, configMode, configFile, configContent });
733
750
  }
734
751
 
735
- const baseDir = scope === 'user'
736
- ? this.userAgentsDir
737
- : this.getProjectAgentsDir(projectPath);
738
-
739
- const filePath = path.join(baseDir, `${fileName}.md`);
752
+ const baseDir = this._getBaseDir(scope, projectPath);
753
+ const { fullPath: filePath } = this._resolveAgentFilePath(baseDir, safeFileName);
740
754
 
741
755
  if (!fs.existsSync(filePath)) {
742
- throw new Error(`代理 "${fileName}" 不存在`);
756
+ throw new Error(`代理 "${safeFileName}" 不存在`);
743
757
  }
744
758
 
745
759
  // 生成文件内容
746
760
  const frontmatterData = {
747
- name: name || fileName,
761
+ name: name || safeFileName,
748
762
  description: description || ''
749
763
  };
750
764
  if (tools) frontmatterData.tools = tools;
@@ -756,24 +770,21 @@ class AgentsService {
756
770
 
757
771
  fs.writeFileSync(filePath, content, 'utf-8');
758
772
 
759
- return this.getAgent(fileName, scope, projectPath);
773
+ return this.getAgent(safeFileName, scope, projectPath);
760
774
  }
761
775
 
762
776
  /**
763
777
  * 删除代理
764
778
  */
765
779
  deleteAgent(fileName, scope, projectPath = null) {
766
- assertSafeAgentFileName(fileName);
780
+ const safeFileName = assertSafeAgentFileName(fileName);
767
781
 
768
782
  if (this.platform === 'codex') {
769
- return this.deleteCodexAgent(fileName, scope);
783
+ return this.deleteCodexAgent(safeFileName, scope);
770
784
  }
771
785
 
772
- const baseDir = scope === 'user'
773
- ? this.userAgentsDir
774
- : this.getProjectAgentsDir(projectPath);
775
-
776
- const filePath = path.join(baseDir, `${fileName}.md`);
786
+ const baseDir = this._getBaseDir(scope, projectPath);
787
+ const { fullPath: filePath } = this._resolveAgentFilePath(baseDir, safeFileName);
777
788
 
778
789
  if (!fs.existsSync(filePath)) {
779
790
  return { success: false, message: '代理不存在' };
@@ -855,23 +866,27 @@ class AgentsService {
855
866
  if (!agent || typeof agent !== 'object') {
856
867
  throw new Error('无效的代理安装参数');
857
868
  }
858
- assertSafeAgentFileName(agent.fileName);
859
- assertSafeRepoPath(agent.repoPath);
860
- return this.repoScanner.installAgent(agent);
869
+ const safeFileName = assertSafeAgentFileName(agent.fileName);
870
+ const safeRepoPath = assertSafeRepoPath(agent.repoPath);
871
+ return this.repoScanner.installAgent({
872
+ ...agent,
873
+ fileName: safeFileName,
874
+ repoPath: safeRepoPath
875
+ });
861
876
  }
862
877
 
863
878
  /**
864
879
  * 卸载代理
865
880
  */
866
881
  uninstallAgent(fileName) {
867
- assertSafeAgentFileName(fileName);
868
- return this.repoScanner.uninstall(`${fileName}.md`);
882
+ const safeFileName = assertSafeAgentFileName(fileName);
883
+ return this.repoScanner.uninstall(`${safeFileName}.md`);
869
884
  }
870
885
 
871
886
  listCodexAgents() {
872
887
  const config = readCodexTomlConfig();
873
888
  const agentsTable = isPlainObject(config.agents) ? config.agents : {};
874
- const agents = [];
889
+ const agentsByName = new Map();
875
890
 
876
891
  for (const [key, value] of Object.entries(agentsTable)) {
877
892
  if (!isPlainObject(value)) {
@@ -901,7 +916,7 @@ class AgentsService {
901
916
  }
902
917
  }
903
918
 
904
- agents.push({
919
+ agentsByName.set(key, {
905
920
  name: key,
906
921
  fileName: key,
907
922
  scope: 'user',
@@ -918,10 +933,86 @@ class AgentsService {
918
933
  configMode,
919
934
  configReadError,
920
935
  resolvedConfigFile,
921
- updatedAt
936
+ updatedAt,
937
+ source: 'codex-config'
922
938
  });
923
939
  }
924
940
 
941
+ if (fs.existsSync(CODEX_AGENTS_DIR)) {
942
+ try {
943
+ const entries = fs.readdirSync(CODEX_AGENTS_DIR, { withFileTypes: true });
944
+ for (const entry of entries) {
945
+ if (!entry.isFile() || !entry.name.endsWith('.toml')) {
946
+ continue;
947
+ }
948
+
949
+ const fileName = entry.name.slice(0, -'.toml'.length);
950
+ try {
951
+ assertSafeAgentFileName(fileName);
952
+ } catch {
953
+ continue;
954
+ }
955
+
956
+ const configFilePath = path.join(CODEX_AGENTS_DIR, entry.name);
957
+ const parsedConfigFile = readCodexAgentConfigFile(configFilePath);
958
+ const existing = agentsByName.get(fileName);
959
+ const model = isPlainObject(parsedConfigFile.data) && typeof parsedConfigFile.data.model === 'string'
960
+ ? parsedConfigFile.data.model
961
+ : '';
962
+ const updatedAt = parsedConfigFile.updatedAt || fs.statSync(configFilePath).mtime.getTime();
963
+
964
+ if (existing) {
965
+ if (!existing.configFile) {
966
+ existing.configFile = configFilePath;
967
+ existing.configMode = inferCodexConfigMode(configFilePath);
968
+ existing.path = configFilePath;
969
+ existing.fullPath = configFilePath;
970
+ existing.resolvedConfigFile = configFilePath;
971
+ }
972
+ if (!existing.fullContent && parsedConfigFile.content) {
973
+ existing.fullContent = parsedConfigFile.content;
974
+ }
975
+ if (!existing.model && model) {
976
+ existing.model = model;
977
+ }
978
+ if (parsedConfigFile.error && !existing.configReadError) {
979
+ existing.configReadError = parsedConfigFile.error;
980
+ }
981
+ existing.updatedAt = Math.max(existing.updatedAt || 0, updatedAt);
982
+ existing.source = existing.source === 'codex-config'
983
+ ? 'codex-config+native-file'
984
+ : existing.source;
985
+ continue;
986
+ }
987
+
988
+ agentsByName.set(fileName, {
989
+ name: fileName,
990
+ fileName,
991
+ scope: 'user',
992
+ path: configFilePath,
993
+ fullPath: configFilePath,
994
+ description: '',
995
+ tools: '',
996
+ model,
997
+ permissionMode: '',
998
+ skills: '',
999
+ systemPrompt: '',
1000
+ fullContent: parsedConfigFile.content,
1001
+ configFile: configFilePath,
1002
+ configMode: inferCodexConfigMode(configFilePath),
1003
+ configReadError: parsedConfigFile.error || '',
1004
+ resolvedConfigFile: configFilePath,
1005
+ updatedAt,
1006
+ source: 'native-file'
1007
+ });
1008
+ }
1009
+ } catch (err) {
1010
+ console.warn('[AgentsService] Failed to scan Codex agents dir:', err.message);
1011
+ }
1012
+ }
1013
+
1014
+ const agents = Array.from(agentsByName.values());
1015
+
925
1016
  agents.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
926
1017
 
927
1018
  return {
@@ -13,6 +13,14 @@ const crypto = require('crypto');
13
13
  const { normalizeGatewaySourceType, normalizeNumber } = require('./proxy-utils');
14
14
  const { resolveChannelWebsiteUrl } = require('../../../config/channel-preset-websites');
15
15
 
16
+ function clearChannelBalanceCache(platform, channel) {
17
+ try {
18
+ require('../channel-balance').clearChannelBalanceCache(platform, channel);
19
+ } catch (_) {
20
+ // Balance cache invalidation is an optimization; channel updates must still succeed.
21
+ }
22
+ }
23
+
16
24
  class BaseChannelService {
17
25
  /**
18
26
  * @param {object} config
@@ -121,6 +129,9 @@ class BaseChannelService {
121
129
  }
122
130
 
123
131
  this.saveChannels(data);
132
+ if (oldChannel.enabled === false && nextChannel.enabled !== false) {
133
+ clearChannelBalanceCache(this.platform, nextChannel);
134
+ }
124
135
  this._onAfterUpdate(oldChannel, nextChannel, data.channels);
125
136
  return nextChannel;
126
137
  }
@@ -154,10 +165,14 @@ class BaseChannelService {
154
165
  }
155
166
 
156
167
  // 单渠道模式:只启用目标渠道
168
+ const wasEnabled = channel.enabled !== false;
157
169
  data.channels.forEach(ch => {
158
170
  ch.enabled = ch.id === channelId;
159
171
  });
160
172
  this.saveChannels(data);
173
+ if (!wasEnabled) {
174
+ clearChannelBalanceCache(this.platform, channel);
175
+ }
161
176
  this._applyToNativeSettings(channel);
162
177
  return channel;
163
178
  }
@@ -218,6 +218,48 @@ function parseNonStreamingUsage(parsed) {
218
218
  return parseSSEUsage(parsed, '');
219
219
  }
220
220
 
221
+ function splitSSEEvents(buffer = '') {
222
+ const normalized = String(buffer || '');
223
+ const events = [];
224
+ let cursor = 0;
225
+ const separator = /\r?\n\r?\n/g;
226
+ let match;
227
+
228
+ while ((match = separator.exec(normalized)) !== null) {
229
+ events.push(normalized.slice(cursor, match.index));
230
+ cursor = separator.lastIndex;
231
+ }
232
+
233
+ return {
234
+ events,
235
+ remainder: normalized.slice(cursor)
236
+ };
237
+ }
238
+
239
+ function parseSSEEventText(eventText = '') {
240
+ if (!String(eventText || '').trim()) {
241
+ return null;
242
+ }
243
+
244
+ let eventType = '';
245
+ const dataLines = [];
246
+
247
+ String(eventText).split(/\r?\n/).forEach((line) => {
248
+ if (line.startsWith('event:')) {
249
+ eventType = line.substring(6).trim();
250
+ } else if (line.startsWith('data:')) {
251
+ dataLines.push(line.substring(5).trim());
252
+ }
253
+ });
254
+
255
+ const data = dataLines.join('\n');
256
+ if (!data || data === '[DONE]') {
257
+ return null;
258
+ }
259
+
260
+ return { eventType, data };
261
+ }
262
+
221
263
  /**
222
264
  * 将解析结果合并到 tokenData 对象。
223
265
  *
@@ -260,6 +302,8 @@ function createTokenData() {
260
302
  module.exports = {
261
303
  parseSSEUsage,
262
304
  parseNonStreamingUsage,
305
+ splitSSEEvents,
306
+ parseSSEEventText,
263
307
  mergeUsageIntoTokenData,
264
308
  createTokenData
265
309
  };
@@ -80,6 +80,19 @@ function forgetBalanceStrategy(key) {
80
80
  }
81
81
  }
82
82
 
83
+ function isRememberedHiddenStrategy(strategy) {
84
+ return strategy?.type === 'unsupported-balance'
85
+ || strategy?.type === 'unavailable-balance';
86
+ }
87
+
88
+ function makeUnavailableBalanceStrategy(snapshot = {}, baseUrl = '') {
89
+ return {
90
+ type: 'unavailable-balance',
91
+ platform: snapshot.platform || null,
92
+ baseUrl: normalizeBaseUrl(baseUrl) || baseUrl || null
93
+ };
94
+ }
95
+
83
96
  function nowIso(now = Date.now()) {
84
97
  return new Date(now).toISOString();
85
98
  }
@@ -1270,6 +1283,12 @@ async function runBalanceStrategy(strategy, token, channel = {}) {
1270
1283
  strategy
1271
1284
  };
1272
1285
  }
1286
+ if (strategy.type === 'unavailable-balance') {
1287
+ return {
1288
+ snapshot: makeHiddenSnapshot(strategy.platform || null),
1289
+ strategy
1290
+ };
1291
+ }
1273
1292
  if (strategy.type === 'siliconflow-user-info') {
1274
1293
  return {
1275
1294
  snapshot: await runSiliconFlowUserInfoStrategy(strategy, token),
@@ -1292,6 +1311,7 @@ async function tryBalanceStrategy(strategy, token, channel = {}) {
1292
1311
 
1293
1312
  async function probeBalanceFromBase(baseUrl, channel, token) {
1294
1313
  let lastSnapshot = null;
1314
+ let lastHiddenStrategy = null;
1295
1315
  for (const strategy of buildBalanceProbeStrategies(baseUrl, channel, token)) {
1296
1316
  const result = await tryBalanceStrategy(strategy, token, channel);
1297
1317
  if (result) return result;
@@ -1304,8 +1324,11 @@ async function probeBalanceFromBase(baseUrl, channel, token) {
1304
1324
  : strategy.platform || null
1305
1325
  );
1306
1326
  }
1327
+ if (isRememberedHiddenStrategy(strategy)) {
1328
+ lastHiddenStrategy = strategy;
1329
+ }
1307
1330
  }
1308
- return lastSnapshot ? { snapshot: lastSnapshot, strategy: null } : null;
1331
+ return lastSnapshot ? { snapshot: lastSnapshot, strategy: lastHiddenStrategy } : null;
1309
1332
  }
1310
1333
 
1311
1334
  function buildCacheKey(source, channel) {
@@ -1341,6 +1364,27 @@ async function refreshChannelBalanceSnapshot(source, channel, options = {}) {
1341
1364
  loadPersistedStrategyCache();
1342
1365
  const cachedStrategy = balanceStrategyCache.get(key);
1343
1366
  if (cachedStrategy) {
1367
+ if (isRememberedHiddenStrategy(cachedStrategy)) {
1368
+ const strategyResult = await runBalanceStrategy(cachedStrategy, token, channel);
1369
+ const hiddenSnapshot = strategyResult?.snapshot || makeHiddenSnapshot(cachedStrategy.platform || null);
1370
+ if (cached?.snapshot?.visible) {
1371
+ const staleSnapshot = {
1372
+ ...cached.snapshot,
1373
+ stale: true
1374
+ };
1375
+ balanceCache.set(key, {
1376
+ snapshot: staleSnapshot,
1377
+ expiresAt: currentTime + CACHE_TTL_MS
1378
+ });
1379
+ return staleSnapshot;
1380
+ }
1381
+ balanceCache.set(key, {
1382
+ snapshot: hiddenSnapshot,
1383
+ expiresAt: currentTime + CACHE_TTL_MS
1384
+ });
1385
+ return hiddenSnapshot;
1386
+ }
1387
+
1344
1388
  const strategyResult = await tryBalanceStrategy(cachedStrategy, token, channel);
1345
1389
  if (strategyResult?.snapshot?.visible) {
1346
1390
  rememberBalanceStrategy(key, strategyResult.strategy);
@@ -1366,6 +1410,13 @@ async function refreshChannelBalanceSnapshot(source, channel, options = {}) {
1366
1410
  lastSnapshot = result?.snapshot || makeHiddenSnapshot(null, { updatedAt: nowIso(currentTime) });
1367
1411
  }
1368
1412
 
1413
+ const hiddenStrategy = lastSnapshot
1414
+ ? makeUnavailableBalanceStrategy(lastSnapshot, bases[0])
1415
+ : null;
1416
+ if (hiddenStrategy) {
1417
+ rememberBalanceStrategy(key, hiddenStrategy);
1418
+ }
1419
+
1369
1420
  if (cached?.snapshot?.visible) {
1370
1421
  const staleSnapshot = {
1371
1422
  ...cached.snapshot,
@@ -1381,6 +1432,38 @@ async function refreshChannelBalanceSnapshot(source, channel, options = {}) {
1381
1432
  return lastSnapshot || makeHiddenSnapshot(null, { updatedAt: nowIso(currentTime) });
1382
1433
  }
1383
1434
 
1435
+ function clearChannelBalanceCache(source, channel) {
1436
+ const normalizedSource = validateSource(source);
1437
+ if (!channel) return;
1438
+ loadPersistedStrategyCache();
1439
+
1440
+ const exactKey = buildCacheKey(normalizedSource, channel);
1441
+ const identity = channel.id || channel.name || 'unknown';
1442
+ const prefix = `${normalizedSource}:${identity}:`;
1443
+ let strategyChanged = false;
1444
+
1445
+ balanceCache.delete(exactKey);
1446
+ for (const key of Array.from(balanceCache.keys())) {
1447
+ if (key.startsWith(prefix)) {
1448
+ balanceCache.delete(key);
1449
+ }
1450
+ }
1451
+
1452
+ if (balanceStrategyCache.delete(exactKey)) {
1453
+ strategyChanged = true;
1454
+ }
1455
+ for (const key of Array.from(balanceStrategyCache.keys())) {
1456
+ if (key.startsWith(prefix)) {
1457
+ balanceStrategyCache.delete(key);
1458
+ strategyChanged = true;
1459
+ }
1460
+ }
1461
+
1462
+ if (strategyChanged) {
1463
+ writePersistedStrategyCache();
1464
+ }
1465
+ }
1466
+
1384
1467
  function getChannelsForSource(source) {
1385
1468
  if (source === 'claude') {
1386
1469
  return require('./channels').getAllChannels() || [];
@@ -1482,8 +1565,10 @@ function clearBalanceCache() {
1482
1565
  module.exports = {
1483
1566
  getChannelBalances,
1484
1567
  refreshChannelBalance,
1568
+ clearChannelBalanceCache,
1485
1569
  _test: {
1486
1570
  clearBalanceCache,
1571
+ clearChannelBalanceCache,
1487
1572
  normalizeBaseUrl,
1488
1573
  buildBaseCandidates,
1489
1574
  buildHubBalanceSnapshot,