coding-tool-x 3.7.2 → 3.7.4

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 (41) hide show
  1. package/dist/web/assets/{Analytics-D5kLOhUo.js → Analytics-BmPIW7oK.js} +1 -1
  2. package/dist/web/assets/{ConfigTemplates-D_-16blp.js → ConfigTemplates-CG4j4ua4.js} +1 -1
  3. package/dist/web/assets/Home-B9yAAZrD.js +1 -0
  4. package/dist/web/assets/Home-DSTUSxrT.css +1 -0
  5. package/dist/web/assets/{PluginManager-Cv_rv8Dh.js → PluginManager-CCJ7mo35.js} +1 -1
  6. package/dist/web/assets/{ProjectList-H_m4Tx4E.js → ProjectList-DVUs3o8x.js} +1 -1
  7. package/dist/web/assets/SessionList-DwssBydw.js +1 -0
  8. package/dist/web/assets/{SkillManager-CRav3m2a.js → SkillManager-DmOTHcQ7.js} +1 -1
  9. package/dist/web/assets/{WorkspaceManager-B3f8R4EF.js → WorkspaceManager-DLVj0zlu.js} +1 -1
  10. package/dist/web/assets/{icons-Dop5j7Bf.js → icons-nGdSKQn2.js} +1 -1
  11. package/dist/web/assets/index-BjdNYwSs.js +2 -0
  12. package/dist/web/assets/index-kQCC5dlu.css +1 -0
  13. package/dist/web/index.html +3 -3
  14. package/package.json +1 -1
  15. package/src/config/paths.js +2 -0
  16. package/src/server/api/channels.js +14 -1
  17. package/src/server/api/codex-channels.js +21 -1
  18. package/src/server/api/codex-projects.js +25 -8
  19. package/src/server/api/dashboard.js +167 -75
  20. package/src/server/api/gemini-channels.js +21 -1
  21. package/src/server/api/gemini-projects.js +23 -7
  22. package/src/server/api/omp-channels.js +17 -1
  23. package/src/server/api/omp-projects.js +23 -6
  24. package/src/server/api/opencode-channels.js +21 -1
  25. package/src/server/api/opencode-projects.js +23 -7
  26. package/src/server/api/projects.js +38 -21
  27. package/src/server/services/channel-balance.js +51 -5
  28. package/src/server/services/channel-sync-utils.js +368 -0
  29. package/src/server/services/channels.js +119 -0
  30. package/src/server/services/codex-channels.js +125 -0
  31. package/src/server/services/gemini-channels.js +123 -0
  32. package/src/server/services/omp-channels.js +299 -2
  33. package/src/server/services/omp-settings-manager.js +2 -0
  34. package/src/server/services/opencode-channels.js +172 -4
  35. package/src/server/services/project-snapshots.js +52 -0
  36. package/src/server/services/snapshot-cache.js +295 -0
  37. package/dist/web/assets/Home-DM_56Fyr.js +0 -1
  38. package/dist/web/assets/Home-Jom_ZcdY.css +0 -1
  39. package/dist/web/assets/SessionList-DcUQvsbR.js +0 -1
  40. package/dist/web/assets/index-Bnni1zPN.js +0 -2
  41. package/dist/web/assets/index-CfMuHG8u.css +0 -1
@@ -29,6 +29,108 @@ const { getTodayStatistics: getCodexTodayStatistics } = require('../services/cod
29
29
  const { getTodayStatistics: getGeminiTodayStatistics } = require('../services/gemini-statistics-service');
30
30
  const { getTodayStatistics: getOpenCodeTodayStatistics } = require('../services/opencode-statistics-service');
31
31
  const { getTodayStatistics: getOmpTodayStatistics } = require('../services/omp-statistics-service');
32
+ const { getSnapshot } = require('../services/snapshot-cache');
33
+ const { projectCountKey } = require('../services/project-snapshots');
34
+
35
+ const SNAPSHOT_TTL_MS = 60 * 1000;
36
+ const EMPTY_COUNTS = { projectCount: 0, sessionCount: 0 };
37
+ const EMPTY_STATS = { requests: 0, tokens: 0, cost: 0, byModel: {}, byChannel: {} };
38
+
39
+ function safeRead(label, reader, fallback) {
40
+ try {
41
+ return reader();
42
+ } catch (error) {
43
+ console.warn(`[Dashboard] ${label} failed:`, error.message);
44
+ return fallback;
45
+ }
46
+ }
47
+
48
+ function channelSnapshotKey(source) {
49
+ return `dashboard:channels:${source}`;
50
+ }
51
+
52
+ function todayStatsSnapshotKey(source) {
53
+ return `dashboard:today-stats:${source}`;
54
+ }
55
+
56
+ function normalizeChannelTokens(tokens) {
57
+ if (tokens && typeof tokens === 'object') {
58
+ return Number(tokens.total ?? tokens.input ?? 0) || 0;
59
+ }
60
+ return Number(tokens) || 0;
61
+ }
62
+
63
+ function normalizeByChannel(byChannel = {}) {
64
+ if (!byChannel || typeof byChannel !== 'object') {
65
+ return {};
66
+ }
67
+
68
+ return Object.fromEntries(Object.entries(byChannel).map(([channelId, item = {}]) => [
69
+ channelId,
70
+ {
71
+ ...item,
72
+ requests: Number(item.requests) || 0,
73
+ tokens: normalizeChannelTokens(item.tokens),
74
+ cost: Number(item.cost) || 0
75
+ }
76
+ ]));
77
+ }
78
+
79
+ // 格式化统计数据:取 summary、byModel 和 byChannel 中的数据
80
+ function formatStats(stats) {
81
+ if (stats && stats.summary) {
82
+ return {
83
+ requests: stats.summary.requests || 0,
84
+ tokens: normalizeChannelTokens(stats.summary.tokens),
85
+ cost: stats.summary.cost || 0,
86
+ byModel: stats.byModel || {},
87
+ byChannel: normalizeByChannel(stats.byChannel || {})
88
+ };
89
+ }
90
+ return { ...EMPTY_STATS };
91
+ }
92
+
93
+ async function readDashboardSnapshot(key, fallbackValue, refresh) {
94
+ return getSnapshot(key, {
95
+ ttlMs: SNAPSHOT_TTL_MS,
96
+ fallbackValue,
97
+ refresh
98
+ });
99
+ }
100
+
101
+ function summarizePart(partSnapshots = {}) {
102
+ const items = {};
103
+ let stale = false;
104
+ let refreshing = false;
105
+ let generatedAt = null;
106
+
107
+ Object.entries(partSnapshots).forEach(([source, snapshot]) => {
108
+ const meta = snapshot.meta || {};
109
+ items[source] = meta;
110
+ stale = stale || Boolean(meta.stale);
111
+ refreshing = refreshing || Boolean(meta.refreshing);
112
+ if (meta.generatedAt && (!generatedAt || meta.generatedAt > generatedAt)) {
113
+ generatedAt = meta.generatedAt;
114
+ }
115
+ });
116
+
117
+ return {
118
+ generatedAt,
119
+ stale,
120
+ refreshing,
121
+ items
122
+ };
123
+ }
124
+
125
+ function dashboardMeta(parts) {
126
+ const partValues = Object.values(parts);
127
+ return {
128
+ generatedAt: new Date().toISOString(),
129
+ stale: partValues.some(part => part.stale),
130
+ refreshing: partValues.some(part => part.refreshing),
131
+ parts
132
+ };
133
+ }
32
134
 
33
135
  /**
34
136
  * GET /api/dashboard/init
@@ -38,20 +140,22 @@ router.get('/init', async (req, res) => {
38
140
  try {
39
141
  const config = loadConfig();
40
142
 
41
- // 并行获取所有数据
143
+ const uiConfig = safeRead('uiConfig', () => loadUIConfig(), {});
144
+ const favorites = safeRead('favorites', () => loadFavorites(), {});
145
+ const proxyStatus = {
146
+ claude: safeRead('claude proxy status', () => getProxyStatus(), {}),
147
+ codex: safeRead('codex proxy status', () => getCodexProxyStatus(), {}),
148
+ gemini: safeRead('gemini proxy status', () => getGeminiProxyStatus(), {}),
149
+ opencode: safeRead('opencode proxy status', () => getOpenCodeProxyStatus(), {}),
150
+ omp: safeRead('omp proxy status', () => getOmpProxyStatus(), {})
151
+ };
152
+
42
153
  const [
43
- uiConfig,
44
- favorites,
45
154
  claudeChannels,
46
155
  codexChannels,
47
156
  geminiChannels,
48
157
  opencodeChannels,
49
158
  ompChannels,
50
- claudeProxyStatus,
51
- codexProxyStatus,
52
- geminiProxyStatus,
53
- opencodeProxyStatus,
54
- ompProxyStatus,
55
159
  claudeTodayStats,
56
160
  codexTodayStats,
57
161
  geminiTodayStats,
@@ -63,52 +167,45 @@ router.get('/init', async (req, res) => {
63
167
  opencodeCounts,
64
168
  ompCounts
65
169
  ] = await Promise.all([
66
- // UI Config
67
- Promise.resolve(loadUIConfig()),
68
-
69
- // Favorites
70
- Promise.resolve(loadFavorites()),
71
-
72
- // Channels
73
- Promise.resolve(getAllChannels()),
74
- Promise.resolve(getCodexChannels()),
75
- Promise.resolve(getGeminiChannels()),
76
- Promise.resolve(getOpenCodeChannels()),
77
- Promise.resolve(getOmpChannels()),
78
-
79
- // Proxy Status
80
- Promise.resolve(getProxyStatus()),
81
- Promise.resolve(getCodexProxyStatus()),
82
- Promise.resolve(getGeminiProxyStatus()),
83
- Promise.resolve(getOpenCodeProxyStatus()),
84
- Promise.resolve(getOmpProxyStatus()),
85
-
86
- // Today Stats (所有平台)
87
- Promise.resolve(getClaudeTodayStatistics()),
88
- Promise.resolve(getCodexTodayStatistics()),
89
- Promise.resolve(getGeminiTodayStatistics()),
90
- Promise.resolve(getOpenCodeTodayStatistics()),
91
- Promise.resolve(getOmpTodayStatistics()),
92
-
93
- // 轻量级统计
94
- Promise.resolve(getClaudeCounts(config)),
95
- Promise.resolve(getCodexCounts()),
96
- Promise.resolve(getGeminiCounts()),
97
- Promise.resolve(getOpenCodeCounts()),
98
- Promise.resolve(getOmpCounts())
170
+ readDashboardSnapshot(channelSnapshotKey('claude'), [], () => getAllChannels()),
171
+ readDashboardSnapshot(channelSnapshotKey('codex'), { channels: [] }, () => getCodexChannels()),
172
+ readDashboardSnapshot(channelSnapshotKey('gemini'), { channels: [] }, () => getGeminiChannels()),
173
+ readDashboardSnapshot(channelSnapshotKey('opencode'), { channels: [] }, () => getOpenCodeChannels()),
174
+ readDashboardSnapshot(channelSnapshotKey('omp'), { channels: [] }, () => getOmpChannels()),
175
+ readDashboardSnapshot(todayStatsSnapshotKey('claude'), null, () => getClaudeTodayStatistics()),
176
+ readDashboardSnapshot(todayStatsSnapshotKey('codex'), null, () => getCodexTodayStatistics()),
177
+ readDashboardSnapshot(todayStatsSnapshotKey('gemini'), null, () => getGeminiTodayStatistics()),
178
+ readDashboardSnapshot(todayStatsSnapshotKey('opencode'), null, () => getOpenCodeTodayStatistics()),
179
+ readDashboardSnapshot(todayStatsSnapshotKey('omp'), null, () => getOmpTodayStatistics()),
180
+ readDashboardSnapshot(projectCountKey('claude'), EMPTY_COUNTS, () => getClaudeCounts(config)),
181
+ readDashboardSnapshot(projectCountKey('codex'), EMPTY_COUNTS, () => getCodexCounts()),
182
+ readDashboardSnapshot(projectCountKey('gemini'), EMPTY_COUNTS, () => getGeminiCounts()),
183
+ readDashboardSnapshot(projectCountKey('opencode'), EMPTY_COUNTS, () => getOpenCodeCounts()),
184
+ readDashboardSnapshot(projectCountKey('omp'), EMPTY_COUNTS, () => getOmpCounts())
99
185
  ]);
100
186
 
101
- // 格式化统计数据:取 summary 和 byModel 中的数据
102
- const formatStats = (stats) => {
103
- if (stats && stats.summary) {
104
- return {
105
- requests: stats.summary.requests || 0,
106
- tokens: stats.summary.tokens || 0,
107
- cost: stats.summary.cost || 0,
108
- byModel: stats.byModel || {}
109
- };
110
- }
111
- return { requests: 0, tokens: 0, cost: 0, byModel: {} };
187
+ const parts = {
188
+ counts: summarizePart({
189
+ claude: claudeCounts,
190
+ codex: codexCounts,
191
+ gemini: geminiCounts,
192
+ opencode: opencodeCounts,
193
+ omp: ompCounts
194
+ }),
195
+ todayStats: summarizePart({
196
+ claude: claudeTodayStats,
197
+ codex: codexTodayStats,
198
+ gemini: geminiTodayStats,
199
+ opencode: opencodeTodayStats,
200
+ omp: ompTodayStats
201
+ }),
202
+ channels: summarizePart({
203
+ claude: claudeChannels,
204
+ codex: codexChannels,
205
+ gemini: geminiChannels,
206
+ opencode: opencodeChannels,
207
+ omp: ompChannels
208
+ })
112
209
  };
113
210
 
114
211
  res.json({
@@ -117,33 +214,28 @@ router.get('/init', async (req, res) => {
117
214
  uiConfig,
118
215
  favorites,
119
216
  channels: {
120
- claude: claudeChannels,
121
- codex: codexChannels,
122
- gemini: geminiChannels,
123
- opencode: opencodeChannels.channels || [],
124
- omp: ompChannels.channels || []
125
- },
126
- proxyStatus: {
127
- claude: claudeProxyStatus,
128
- codex: codexProxyStatus,
129
- gemini: geminiProxyStatus,
130
- opencode: opencodeProxyStatus,
131
- omp: ompProxyStatus
217
+ claude: claudeChannels.value,
218
+ codex: codexChannels.value,
219
+ gemini: geminiChannels.value,
220
+ opencode: opencodeChannels.value.channels || [],
221
+ omp: ompChannels.value.channels || []
132
222
  },
223
+ proxyStatus,
133
224
  counts: {
134
- claude: claudeCounts || { projectCount: 0, sessionCount: 0 },
135
- codex: codexCounts || { projectCount: 0, sessionCount: 0 },
136
- gemini: geminiCounts || { projectCount: 0, sessionCount: 0 },
137
- opencode: opencodeCounts || { projectCount: 0, sessionCount: 0 },
138
- omp: ompCounts || { projectCount: 0, sessionCount: 0 }
225
+ claude: claudeCounts.value || EMPTY_COUNTS,
226
+ codex: codexCounts.value || EMPTY_COUNTS,
227
+ gemini: geminiCounts.value || EMPTY_COUNTS,
228
+ opencode: opencodeCounts.value || EMPTY_COUNTS,
229
+ omp: ompCounts.value || EMPTY_COUNTS
139
230
  },
140
231
  todayStats: {
141
- claude: formatStats(claudeTodayStats),
142
- codex: formatStats(codexTodayStats),
143
- gemini: formatStats(geminiTodayStats),
144
- opencode: formatStats(opencodeTodayStats),
145
- omp: formatStats(ompTodayStats)
146
- }
232
+ claude: formatStats(claudeTodayStats.value),
233
+ codex: formatStats(codexTodayStats.value),
234
+ gemini: formatStats(geminiTodayStats.value),
235
+ opencode: formatStats(opencodeTodayStats.value),
236
+ omp: formatStats(ompTodayStats.value)
237
+ },
238
+ meta: dashboardMeta(parts)
147
239
  }
148
240
  });
149
241
  } catch (error) {
@@ -6,7 +6,8 @@ const {
6
6
  updateChannel,
7
7
  deleteChannel,
8
8
  getEnabledChannels,
9
- saveChannelOrder
9
+ saveChannelOrder,
10
+ syncCurrentGeminiChannel
10
11
  } = require('../services/gemini-channels');
11
12
  const { getSchedulerState } = require('../services/channel-scheduler');
12
13
  const { getChannelHealthStatus, resetChannelHealth } = require('../services/channel-health');
@@ -53,6 +54,25 @@ module.exports = (config) => {
53
54
  }
54
55
  });
55
56
 
57
+ /**
58
+ * POST /api/gemini/channels/sync-current
59
+ * 将当前 Gemini 原生/ctx 配置同步到渠道列表
60
+ */
61
+ router.post('/sync-current', (req, res) => {
62
+ try {
63
+ if (!isGeminiInstalled()) {
64
+ return res.status(404).json({ error: 'Gemini CLI not installed' });
65
+ }
66
+
67
+ const result = syncCurrentGeminiChannel();
68
+ res.json(result);
69
+ broadcastSchedulerState('gemini', getSchedulerState('gemini'));
70
+ } catch (err) {
71
+ console.error('[Gemini Channels API] Failed to sync current channel:', err);
72
+ res.status(500).json({ error: err.message });
73
+ }
74
+ });
75
+
56
76
  /**
57
77
  * GET /api/gemini/channels/:id/models
58
78
  * 获取渠道可用模型列表
@@ -2,29 +2,43 @@ const express = require('express');
2
2
  const router = express.Router();
3
3
  const { getProjects, saveProjectOrder, deleteProject } = require('../services/gemini-sessions');
4
4
  const { isGeminiInstalled } = require('../services/gemini-config');
5
+ const {
6
+ emptyProjectList,
7
+ getProjectListSnapshot,
8
+ invalidateProjectSnapshots
9
+ } = require('../services/project-snapshots');
5
10
 
6
11
  module.exports = (config) => {
12
+ function buildProjectsPayload() {
13
+ const projects = getProjects();
14
+ return {
15
+ projects,
16
+ currentProject: projects[0] ? projects[0].name : null
17
+ };
18
+ }
19
+
7
20
  /**
8
21
  * GET /api/gemini/projects
9
22
  * 获取所有 Gemini 项目列表
10
23
  */
11
- router.get('/', (req, res) => {
24
+ router.get('/', async (req, res) => {
12
25
  try {
13
26
  // 检查 Gemini 是否安装
14
27
  if (!isGeminiInstalled()) {
15
28
  return res.json({
16
29
  projects: [],
17
30
  currentProject: null,
18
- error: 'Gemini CLI not installed or not found'
31
+ error: 'Gemini CLI not installed or not found',
32
+ meta: { generatedAt: new Date().toISOString(), stale: false, refreshing: false, fallback: true, error: null }
19
33
  });
20
34
  }
21
35
 
22
- const projects = getProjects();
23
-
24
- res.json({
25
- projects,
26
- currentProject: projects[0] ? projects[0].name : null
36
+ const snapshot = await getProjectListSnapshot('gemini', {
37
+ fallbackValue: emptyProjectList(null),
38
+ force: req.query?.fresh === '1',
39
+ refresh: buildProjectsPayload
27
40
  });
41
+ res.json({ ...snapshot.value, meta: snapshot.meta });
28
42
  } catch (err) {
29
43
  console.error('[Gemini API] Failed to get projects:', err);
30
44
 
@@ -59,6 +73,7 @@ module.exports = (config) => {
59
73
  }
60
74
 
61
75
  saveProjectOrder(order);
76
+ invalidateProjectSnapshots('gemini');
62
77
 
63
78
  res.json({ success: true });
64
79
  } catch (err) {
@@ -79,6 +94,7 @@ module.exports = (config) => {
79
94
 
80
95
  const { projectHash } = req.params;
81
96
  const result = deleteProject(projectHash);
97
+ invalidateProjectSnapshots('gemini');
82
98
 
83
99
  res.json(result);
84
100
  } catch (err) {
@@ -5,7 +5,8 @@ const {
5
5
  createChannel,
6
6
  updateChannel,
7
7
  deleteChannel,
8
- saveChannelOrder
8
+ saveChannelOrder,
9
+ syncCurrentOmpChannel
9
10
  } = require('../services/omp-channels');
10
11
  const { isOmpInstalled } = require('../services/omp-sessions');
11
12
  const { getSchedulerState } = require('../services/channel-scheduler');
@@ -171,6 +172,21 @@ module.exports = () => {
171
172
  }
172
173
  });
173
174
 
175
+ router.post('/sync-current', (req, res) => {
176
+ try {
177
+ if (!isOmpInstalled()) {
178
+ return res.status(404).json({ error: 'OMP CLI not installed' });
179
+ }
180
+
181
+ const result = syncCurrentOmpChannel();
182
+ res.json(result);
183
+ broadcastSchedulerState('omp', getSchedulerState('omp'));
184
+ } catch (err) {
185
+ console.error('[OMP Channels API] Failed to sync current channel:', err);
186
+ res.status(500).json({ error: err.message });
187
+ }
188
+ });
189
+
174
190
  router.post('/probe-models', async (req, res) => {
175
191
  try {
176
192
  const {
@@ -6,27 +6,42 @@ const {
6
6
  deleteProject,
7
7
  isOmpInstalled
8
8
  } = require('../services/omp-sessions');
9
+ const {
10
+ emptyProjectList,
11
+ getProjectListSnapshot,
12
+ invalidateProjectSnapshots
13
+ } = require('../services/project-snapshots');
9
14
 
10
15
  function isNotFoundError(error) {
11
16
  return !!(error && error.message === 'Project not found');
12
17
  }
13
18
 
14
19
  module.exports = () => {
15
- router.get('/', (req, res) => {
20
+ function buildProjectsPayload() {
21
+ const projects = getProjects();
22
+ return {
23
+ projects,
24
+ currentProject: projects[0] ? projects[0].name : null
25
+ };
26
+ }
27
+
28
+ router.get('/', async (req, res) => {
16
29
  try {
17
30
  if (!isOmpInstalled()) {
18
31
  return res.json({
19
32
  projects: [],
20
33
  currentProject: null,
21
- error: 'OMP CLI not installed or not found'
34
+ error: 'OMP CLI not installed or not found',
35
+ meta: { generatedAt: new Date().toISOString(), stale: false, refreshing: false, fallback: true, error: null }
22
36
  });
23
37
  }
24
38
 
25
- const projects = getProjects();
26
- res.json({
27
- projects,
28
- currentProject: projects[0] ? projects[0].name : null
39
+ const snapshot = await getProjectListSnapshot('omp', {
40
+ fallbackValue: emptyProjectList(null),
41
+ force: req.query?.fresh === '1',
42
+ refresh: buildProjectsPayload
29
43
  });
44
+ res.json({ ...snapshot.value, meta: snapshot.meta });
30
45
  } catch (err) {
31
46
  console.error('[OMP API] Failed to get projects:', err);
32
47
  if (err.code === 'ENOENT') {
@@ -49,6 +64,7 @@ module.exports = () => {
49
64
  return res.status(400).json({ error: 'order must be an array' });
50
65
  }
51
66
  saveProjectOrder(order);
67
+ invalidateProjectSnapshots('omp');
52
68
  res.json({ success: true });
53
69
  } catch (err) {
54
70
  console.error('[OMP API] Failed to save project order:', err);
@@ -62,6 +78,7 @@ module.exports = () => {
62
78
  return res.status(404).json({ error: 'OMP CLI not installed' });
63
79
  }
64
80
  const result = deleteProject(req.params.projectName);
81
+ invalidateProjectSnapshots('omp');
65
82
  res.json(result);
66
83
  } catch (err) {
67
84
  if (isNotFoundError(err)) {
@@ -5,7 +5,8 @@ const {
5
5
  createChannel,
6
6
  updateChannel,
7
7
  deleteChannel,
8
- saveChannelOrder
8
+ saveChannelOrder,
9
+ syncCurrentOpenCodeChannel
9
10
  } = require('../services/opencode-channels');
10
11
  const { isOpenCodeInstalled } = require('../services/opencode-sessions');
11
12
  const { getSchedulerState } = require('../services/channel-scheduler');
@@ -131,6 +132,25 @@ module.exports = (config) => {
131
132
  }
132
133
  });
133
134
 
135
+ /**
136
+ * POST /api/opencode/channels/sync-current
137
+ * 将当前 OpenCode 原生/ctx 配置同步到渠道列表
138
+ */
139
+ router.post('/sync-current', (req, res) => {
140
+ try {
141
+ if (!isOpenCodeInstalled()) {
142
+ return res.status(404).json({ error: 'OpenCode CLI not installed' });
143
+ }
144
+
145
+ const result = syncCurrentOpenCodeChannel();
146
+ res.json(result);
147
+ broadcastSchedulerState('opencode', getSchedulerState('opencode'));
148
+ } catch (err) {
149
+ console.error('[OpenCode Channels API] Failed to sync current channel:', err);
150
+ res.status(500).json({ error: err.message });
151
+ }
152
+ });
153
+
134
154
  /**
135
155
  * POST /api/opencode/channels/probe-models
136
156
  * 用临时配置(新建渠道时)获取模型列表,无需 channelId
@@ -6,32 +6,46 @@ const {
6
6
  deleteProject,
7
7
  isOpenCodeInstalled
8
8
  } = require('../services/opencode-sessions');
9
+ const {
10
+ emptyProjectList,
11
+ getProjectListSnapshot,
12
+ invalidateProjectSnapshots
13
+ } = require('../services/project-snapshots');
9
14
 
10
15
  function isNotFoundError(error) {
11
16
  return !!(error && error.message === 'Project not found');
12
17
  }
13
18
 
14
19
  module.exports = (config) => {
20
+ function buildProjectsPayload() {
21
+ const projects = getProjects();
22
+ return {
23
+ projects,
24
+ currentProject: projects[0] ? projects[0].name : null
25
+ };
26
+ }
27
+
15
28
  /**
16
29
  * GET /api/opencode/projects
17
30
  * 获取所有 OpenCode 项目列表
18
31
  */
19
- router.get('/', (req, res) => {
32
+ router.get('/', async (req, res) => {
20
33
  try {
21
34
  if (!isOpenCodeInstalled()) {
22
35
  return res.json({
23
36
  projects: [],
24
37
  currentProject: null,
25
- error: 'OpenCode CLI not installed or not found'
38
+ error: 'OpenCode CLI not installed or not found',
39
+ meta: { generatedAt: new Date().toISOString(), stale: false, refreshing: false, fallback: true, error: null }
26
40
  });
27
41
  }
28
42
 
29
- const projects = getProjects();
30
-
31
- res.json({
32
- projects,
33
- currentProject: projects[0] ? projects[0].name : null
43
+ const snapshot = await getProjectListSnapshot('opencode', {
44
+ fallbackValue: emptyProjectList(null),
45
+ force: req.query?.fresh === '1',
46
+ refresh: buildProjectsPayload
34
47
  });
48
+ res.json({ ...snapshot.value, meta: snapshot.meta });
35
49
  } catch (err) {
36
50
  console.error('[OpenCode API] Failed to get projects:', err);
37
51
 
@@ -65,6 +79,7 @@ module.exports = (config) => {
65
79
  }
66
80
 
67
81
  saveProjectOrder(order);
82
+ invalidateProjectSnapshots('opencode');
68
83
  res.json({ success: true });
69
84
  } catch (err) {
70
85
  console.error('[OpenCode API] Failed to save project order:', err);
@@ -84,6 +99,7 @@ module.exports = (config) => {
84
99
 
85
100
  const { projectName } = req.params;
86
101
  const result = deleteProject(projectName);
102
+ invalidateProjectSnapshots('opencode');
87
103
  res.json(result);
88
104
  } catch (err) {
89
105
  if (isNotFoundError(err)) {
@@ -1,33 +1,47 @@
1
1
  const express = require('express');
2
2
  const router = express.Router();
3
3
  const { getProjectsWithStats, saveProjectOrder, getProjectOrder, deleteProject } = require('../services/sessions');
4
+ const {
5
+ emptyProjectList,
6
+ getProjectListSnapshot,
7
+ invalidateProjectSnapshots
8
+ } = require('../services/project-snapshots');
4
9
 
5
10
  module.exports = (config) => {
11
+ async function buildProjectsPayload() {
12
+ const projects = await getProjectsWithStats(config);
13
+ const order = getProjectOrder(config);
14
+
15
+ // Sort projects by saved order
16
+ let sortedProjects = projects;
17
+ if (order && order.length > 0) {
18
+ const orderMap = new Map(order.map((name, idx) => [name, idx]));
19
+ sortedProjects = [...projects].sort((a, b) => {
20
+ const aIdx = orderMap.has(a.name) ? orderMap.get(a.name) : 999999;
21
+ const bIdx = orderMap.has(b.name) ? orderMap.get(b.name) : 999999;
22
+ if (aIdx === bIdx) {
23
+ // Both are new, sort by lastUsed
24
+ return (b.lastUsed || 0) - (a.lastUsed || 0);
25
+ }
26
+ return aIdx - bIdx;
27
+ });
28
+ }
29
+
30
+ return {
31
+ projects: sortedProjects,
32
+ currentProject: config.currentProject || (sortedProjects[0] ? sortedProjects[0].name : null)
33
+ };
34
+ }
35
+
6
36
  // GET /api/projects - Get all projects with stats
7
37
  router.get('/', async (req, res) => {
8
38
  try {
9
- const projects = await getProjectsWithStats(config);
10
- const order = getProjectOrder(config);
11
-
12
- // Sort projects by saved order
13
- let sortedProjects = projects;
14
- if (order && order.length > 0) {
15
- const orderMap = new Map(order.map((name, idx) => [name, idx]));
16
- sortedProjects = [...projects].sort((a, b) => {
17
- const aIdx = orderMap.has(a.name) ? orderMap.get(a.name) : 999999;
18
- const bIdx = orderMap.has(b.name) ? orderMap.get(b.name) : 999999;
19
- if (aIdx === bIdx) {
20
- // Both are new, sort by lastUsed
21
- return (b.lastUsed || 0) - (a.lastUsed || 0);
22
- }
23
- return aIdx - bIdx;
24
- });
25
- }
26
-
27
- res.json({
28
- projects: sortedProjects,
29
- currentProject: config.currentProject || (sortedProjects[0] ? sortedProjects[0].name : null)
39
+ const snapshot = await getProjectListSnapshot('claude', {
40
+ fallbackValue: emptyProjectList(config.currentProject || null),
41
+ force: req.query?.fresh === '1',
42
+ refresh: buildProjectsPayload
30
43
  });
44
+ res.json({ ...snapshot.value, meta: snapshot.meta });
31
45
  } catch (error) {
32
46
  console.error('Error fetching projects:', error);
33
47
  res.status(500).json({ error: error.message });
@@ -42,6 +56,7 @@ module.exports = (config) => {
42
56
  return res.status(400).json({ error: 'Order must be an array' });
43
57
  }
44
58
  saveProjectOrder(config, order);
59
+ invalidateProjectSnapshots('claude');
45
60
  res.json({ success: true });
46
61
  } catch (error) {
47
62
  console.error('Error saving project order:', error);
@@ -96,6 +111,7 @@ module.exports = (config) => {
96
111
  'utf8'
97
112
  );
98
113
 
114
+ invalidateProjectSnapshots('claude');
99
115
  res.json({
100
116
  success: true,
101
117
  projectName,
@@ -113,6 +129,7 @@ module.exports = (config) => {
113
129
  try {
114
130
  const { projectName } = req.params;
115
131
  deleteProject(config, projectName);
132
+ invalidateProjectSnapshots('claude');
116
133
  res.json({ success: true });
117
134
  } catch (error) {
118
135
  console.error('Error deleting project:', error);