coding-tool-x 3.5.10 → 3.5.12

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 (48) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/README.md +11 -1
  3. package/dist/web/assets/{Analytics-DPb1zErn.js → Analytics-GuTri2mr.js} +1 -1
  4. package/dist/web/assets/{ConfigTemplates-Bki2k4Lo.js → ConfigTemplates-DILbDhRj.js} +1 -1
  5. package/dist/web/assets/Home-Bm7mK2qz.css +1 -0
  6. package/dist/web/assets/Home-ri7cXeJp.js +1 -0
  7. package/dist/web/assets/{PluginManager-jgeQOE24.js → PluginManager-DQPeUFkr.js} +1 -1
  8. package/dist/web/assets/{ProjectList-CF67RzP_.js → ProjectList-Cpf82Lgb.js} +1 -1
  9. package/dist/web/assets/SessionList-D12muXvx.css +1 -0
  10. package/dist/web/assets/{SessionList-DhHsY5q9.js → SessionList-DXhPIwJI.js} +1 -1
  11. package/dist/web/assets/{SkillManager-CtEz5gGp.js → SkillManager-DNVyDlEv.js} +1 -1
  12. package/dist/web/assets/{WorkspaceManager-D3B0Y5XE.js → WorkspaceManager-Cp0t7cjn.js} +1 -1
  13. package/dist/web/assets/icons-QFXzEgqM.js +1 -0
  14. package/dist/web/assets/index-C5wcoiDy.js +2 -0
  15. package/dist/web/assets/{index-BS7aplYp.css → index-DfikIh5S.css} +1 -1
  16. package/dist/web/index.html +4 -4
  17. package/package.json +2 -1
  18. package/src/commands/daemon.js +6 -2
  19. package/src/commands/ui.js +8 -3
  20. package/src/index.js +2 -0
  21. package/src/server/api/codex-channels.js +4 -0
  22. package/src/server/api/codex-proxy.js +42 -1
  23. package/src/server/api/hooks.js +18 -1
  24. package/src/server/codex-proxy-server.js +22 -6
  25. package/src/server/gemini-proxy-server.js +3 -1
  26. package/src/server/index.js +37 -5
  27. package/src/server/opencode-proxy-server.js +27 -8
  28. package/src/server/proxy-server.js +3 -1
  29. package/src/server/services/base/proxy-utils.js +41 -0
  30. package/src/server/services/base/response-usage-parser.js +109 -58
  31. package/src/server/services/codex-sessions.js +13 -3
  32. package/src/server/services/codex-statistics-service.js +1 -0
  33. package/src/server/services/gemini-sessions.js +12 -4
  34. package/src/server/services/gemini-statistics-service.js +1 -0
  35. package/src/server/services/https-cert.js +202 -0
  36. package/src/server/services/notification-hooks.js +138 -6
  37. package/src/server/services/opencode-gateway-converter.js +3 -0
  38. package/src/server/services/opencode-statistics-service.js +1 -0
  39. package/src/server/services/proxy-log-helper.js +44 -12
  40. package/src/server/services/sessions.js +13 -3
  41. package/src/server/services/statistics-service.js +73 -28
  42. package/src/server/services/web-build.js +172 -0
  43. package/src/server/websocket-server.js +18 -1
  44. package/dist/web/assets/Home-BKYHKSax.js +0 -1
  45. package/dist/web/assets/Home-BQxQ1LhR.css +0 -1
  46. package/dist/web/assets/SessionList-lz23U6ZJ.css +0 -1
  47. package/dist/web/assets/icons-lKCTySl7.js +0 -1
  48. package/dist/web/assets/index-BnyNm74G.js +0 -2
@@ -11,6 +11,8 @@
11
11
  * - OpenAI Chat Completions API (/v1/chat/completions)
12
12
  */
13
13
 
14
+ const { ensureOpenAiStreamUsage } = require('./base/proxy-utils');
15
+
14
16
  const SUPPORTED_SOURCE_TYPES = ['claude', 'codex', 'gemini'];
15
17
  const SUPPORTED_TARGET_APIS = ['responses', 'chat.completions'];
16
18
 
@@ -554,6 +556,7 @@ function buildChatCompletionsRequest(normalized, options, warnings) {
554
556
  if (chatTools.length > 0) request.tools = chatTools;
555
557
  }
556
558
 
559
+ ensureOpenAiStreamUsage(request);
557
560
  return request;
558
561
  }
559
562
 
@@ -110,6 +110,7 @@ function recordRequest(requestData = {}) {
110
110
  input: normalizedTokens.input,
111
111
  output: normalizedTokens.output,
112
112
  reasoning: normalizedTokens.reasoning,
113
+ cached: normalizedTokens.cached,
113
114
  cacheCreation: normalizedTokens.cacheCreation,
114
115
  cacheRead: normalizedTokens.cacheRead,
115
116
  total: normalizedTokens.total
@@ -12,6 +12,30 @@ function normalizeToolSource(source = '') {
12
12
  return 'claude';
13
13
  }
14
14
 
15
+ function normalizeModelName(value = '') {
16
+ return typeof value === 'string' ? value.trim() : '';
17
+ }
18
+
19
+ function resolveActualModel(model = '', metadata = {}) {
20
+ const candidates = [
21
+ model,
22
+ metadata.redirectedModel,
23
+ metadata.modelFromUrl,
24
+ metadata.requestModel,
25
+ metadata.model,
26
+ metadata.originalModel
27
+ ];
28
+
29
+ for (const candidate of candidates) {
30
+ const normalized = normalizeModelName(candidate);
31
+ if (normalized) {
32
+ return normalized;
33
+ }
34
+ }
35
+
36
+ return '';
37
+ }
38
+
15
39
  function normalizeUsageTokens(source, tokens = {}) {
16
40
  const normalizedSource = normalizeToolSource(source);
17
41
  const input = toNumber(tokens.input);
@@ -68,7 +92,8 @@ function buildSuccessLogPayload({
68
92
  redirectedModel,
69
93
  tokens,
70
94
  cost = 0,
71
- timestamp = Date.now()
95
+ timestamp = Date.now(),
96
+ usageMissing = false
72
97
  }) {
73
98
  const normalized = normalizeUsageTokens(source, tokens);
74
99
  const payload = {
@@ -87,7 +112,8 @@ function buildSuccessLogPayload({
87
112
  totalTokens: normalized.total,
88
113
  cost,
89
114
  source: normalizeToolSource(source),
90
- timestamp
115
+ timestamp,
116
+ usageMissing: Boolean(usageMissing)
91
117
  };
92
118
  if (originalModel) {
93
119
  payload.originalModel = originalModel;
@@ -151,40 +177,43 @@ function publishUsageLog({
151
177
  recordSuccess,
152
178
  allowBroadcast = true
153
179
  }) {
154
- if (!hasMeaningfulUsage(source, tokens)) {
155
- return null;
156
- }
157
-
158
180
  const normalizedSource = normalizeToolSource(source);
181
+ const actualModel = resolveActualModel(model, metadata);
159
182
  const normalizedTokens = normalizeUsageTokens(normalizedSource, tokens);
183
+ const usageAvailable = hasMeaningfulUsage(normalizedSource, normalizedTokens);
160
184
  const requestId = metadata.id || `${normalizedSource}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
161
185
  const timestamp = Date.now();
162
186
  const cost = typeof calculateCost === 'function'
163
- ? calculateCost(model || '', normalizedTokens)
187
+ ? calculateCost(actualModel || '', normalizedTokens)
164
188
  : 0;
165
189
 
190
+ if (!usageAvailable && !actualModel) {
191
+ return null;
192
+ }
193
+
166
194
  if (allowBroadcast && typeof broadcastLog === 'function') {
167
195
  broadcastLog(buildSuccessLogPayload({
168
196
  source: normalizedSource,
169
197
  requestId,
170
198
  channel: metadata.channel,
171
- model,
199
+ model: actualModel,
172
200
  originalModel: metadata.originalModel,
173
201
  redirectedModel: metadata.redirectedModel,
174
202
  tokens: normalizedTokens,
175
203
  cost,
176
- timestamp
204
+ timestamp,
205
+ usageMissing: !usageAvailable
177
206
  }));
178
207
  }
179
208
 
180
- if (typeof recordRequest === 'function') {
209
+ if (usageAvailable && typeof recordRequest === 'function') {
181
210
  const entry = {
182
211
  id: requestId,
183
212
  timestamp: new Date(metadata.startTime || timestamp).toISOString(),
184
213
  toolType: normalizedSource === 'claude' ? 'claude-code' : normalizedSource,
185
214
  channel: metadata.channel,
186
215
  channelId: metadata.channelId,
187
- model: model || '',
216
+ model: actualModel || '',
188
217
  tokens: {
189
218
  input: normalizedTokens.input,
190
219
  output: normalizedTokens.output,
@@ -213,8 +242,10 @@ function publishUsageLog({
213
242
 
214
243
  return {
215
244
  cost,
245
+ model: actualModel,
216
246
  tokens: normalizedTokens,
217
- timestamp
247
+ timestamp,
248
+ usageMissing: !usageAvailable
218
249
  };
219
250
  }
220
251
 
@@ -250,6 +281,7 @@ function publishFailureLog({
250
281
  module.exports = {
251
282
  toNumber,
252
283
  normalizeToolSource,
284
+ resolveActualModel,
253
285
  normalizeUsageTokens,
254
286
  hasMeaningfulUsage,
255
287
  formatRealtimeTime,
@@ -135,6 +135,7 @@ function sliceClaudeSessionContentByUserMessage(content, afterUserMessageNumber)
135
135
  const { lines, eol, hasTrailingEol } = splitTextPreserveEol(content);
136
136
  const keptLines = [];
137
137
  let matchedUserMessages = 0;
138
+ let targetUserReached = false;
138
139
 
139
140
  for (const line of lines) {
140
141
  if (!line.trim()) {
@@ -150,23 +151,32 @@ function sliceClaudeSessionContentByUserMessage(content, afterUserMessageNumber)
150
151
  continue;
151
152
  }
152
153
 
153
- keptLines.push(line);
154
-
155
154
  if (json.type !== 'user') {
155
+ keptLines.push(line);
156
156
  continue;
157
157
  }
158
158
 
159
159
  const userText = getClaudeForkMessageText(json.message?.content);
160
160
  if (!userText) {
161
+ keptLines.push(line);
161
162
  continue;
162
163
  }
163
164
 
165
+ if (targetUserReached) {
166
+ return joinTextPreserveEol(keptLines, eol, hasTrailingEol);
167
+ }
168
+
164
169
  matchedUserMessages += 1;
170
+ keptLines.push(line);
165
171
  if (matchedUserMessages >= afterUserMessageNumber) {
166
- return joinTextPreserveEol(keptLines, eol, hasTrailingEol);
172
+ targetUserReached = true;
167
173
  }
168
174
  }
169
175
 
176
+ if (targetUserReached) {
177
+ return joinTextPreserveEol(keptLines, eol, hasTrailingEol);
178
+ }
179
+
170
180
  throw new Error(`afterUserMessageNumber ${afterUserMessageNumber} exceeds available user messages (${matchedUserMessages})`);
171
181
  }
172
182
 
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { PATHS } = require('../../config/paths');
4
+ const { normalizeUsageTokens, resolveActualModel } = require('./proxy-log-helper');
4
5
 
5
6
  // 北京时间辅助(UTC+8),统一所有时间计算
6
7
  const CST_OFFSET_MS = 8 * 60 * 60 * 1000;
@@ -189,6 +190,8 @@ function initStatsObject() {
189
190
  output: 0,
190
191
  cacheCreation: 0,
191
192
  cacheRead: 0,
193
+ cached: 0,
194
+ reasoning: 0,
192
195
  total: 0
193
196
  },
194
197
  cost: 0
@@ -202,7 +205,9 @@ function updateStats(stats, tokens, cost) {
202
205
  stats.tokens.output += tokens.output || 0;
203
206
  stats.tokens.cacheCreation += tokens.cacheCreation || 0;
204
207
  stats.tokens.cacheRead += tokens.cacheRead || 0;
205
- stats.tokens.total += tokens.total || 0;
208
+ stats.tokens.cached += tokens.cached || 0;
209
+ stats.tokens.reasoning += tokens.reasoning || 0;
210
+ stats.tokens.total += getTokenTotal(tokens);
206
211
  stats.cost += cost || 0;
207
212
  }
208
213
 
@@ -226,6 +231,9 @@ function recordRequest(requestData) {
226
231
  session,
227
232
  project
228
233
  } = requestData;
234
+ const resolvedModel = resolveActualModel(model, requestData);
235
+ const modelKey = resolvedModel || 'unknown';
236
+ const totalTokens = getTokenTotal(tokens);
229
237
 
230
238
  // 1. 写入详细日志
231
239
  const logEntry = {
@@ -234,7 +242,7 @@ function recordRequest(requestData) {
234
242
  toolType,
235
243
  channel,
236
244
  channelId,
237
- model,
245
+ model: resolvedModel,
238
246
  tokens,
239
247
  duration,
240
248
  success,
@@ -256,7 +264,7 @@ function recordRequest(requestData) {
256
264
 
257
265
  // 更新全局统计
258
266
  globalStats.global.totalRequests += 1;
259
- globalStats.global.totalTokens += tokens.total || 0;
267
+ globalStats.global.totalTokens += totalTokens;
260
268
  globalStats.global.totalCost += cost || 0;
261
269
 
262
270
  // 按工具类型统计
@@ -283,10 +291,10 @@ function recordRequest(requestData) {
283
291
  updateStats(globalStats.byToolType[toolType].channels[channelId], tokens, cost);
284
292
 
285
293
  // 按工具类型 -> 模型统计
286
- if (!globalStats.byToolType[toolType].models[model]) {
287
- globalStats.byToolType[toolType].models[model] = initStatsObject();
294
+ if (!globalStats.byToolType[toolType].models[modelKey]) {
295
+ globalStats.byToolType[toolType].models[modelKey] = initStatsObject();
288
296
  }
289
- updateStats(globalStats.byToolType[toolType].models[model], tokens, cost);
297
+ updateStats(globalStats.byToolType[toolType].models[modelKey], tokens, cost);
290
298
 
291
299
  // 按渠道统计(跨工具)
292
300
  if (!globalStats.byChannel[channelId]) {
@@ -303,13 +311,13 @@ function recordRequest(requestData) {
303
311
  updateStats(globalStats.byChannel[channelId], tokens, cost);
304
312
 
305
313
  // 按模型统计(跨工具)
306
- if (!globalStats.byModel[model]) {
307
- globalStats.byModel[model] = {
314
+ if (!globalStats.byModel[modelKey]) {
315
+ globalStats.byModel[modelKey] = {
308
316
  toolType,
309
317
  ...initStatsObject()
310
318
  };
311
319
  }
312
- updateStats(globalStats.byModel[model], tokens, cost);
320
+ updateStats(globalStats.byModel[modelKey], tokens, cost);
313
321
 
314
322
  saveStatistics(globalStats);
315
323
 
@@ -321,7 +329,7 @@ function recordRequest(requestData) {
321
329
 
322
330
  // 更新每日汇总
323
331
  dailyStats.summary.requests += 1;
324
- dailyStats.summary.tokens += tokens.total || 0;
332
+ dailyStats.summary.tokens += totalTokens;
325
333
  dailyStats.summary.cost += cost || 0;
326
334
 
327
335
  // 按小时统计
@@ -361,6 +369,15 @@ function recordRequest(requestData) {
361
369
  }
362
370
  updateStats(dailyStats.byToolType[toolType].channels[channelId], tokens, cost);
363
371
 
372
+ // 按工具类型 -> 模型
373
+ if (!dailyStats.byToolType[toolType].models) {
374
+ dailyStats.byToolType[toolType].models = {};
375
+ }
376
+ if (!dailyStats.byToolType[toolType].models[modelKey]) {
377
+ dailyStats.byToolType[toolType].models[modelKey] = initStatsObject();
378
+ }
379
+ updateStats(dailyStats.byToolType[toolType].models[modelKey], tokens, cost);
380
+
364
381
  // 按渠道统计
365
382
  if (!dailyStats.byChannel[channelId]) {
366
383
  dailyStats.byChannel[channelId] = {
@@ -372,13 +389,13 @@ function recordRequest(requestData) {
372
389
  updateStats(dailyStats.byChannel[channelId], tokens, cost);
373
390
 
374
391
  // 按模型统计
375
- if (!dailyStats.byModel[model]) {
376
- dailyStats.byModel[model] = {
392
+ if (!dailyStats.byModel[modelKey]) {
393
+ dailyStats.byModel[modelKey] = {
377
394
  toolType,
378
395
  ...initStatsObject()
379
396
  };
380
397
  }
381
- updateStats(dailyStats.byModel[model], tokens, cost);
398
+ updateStats(dailyStats.byModel[modelKey], tokens, cost);
382
399
 
383
400
  saveDailyStats(date, dailyStats);
384
401
 
@@ -447,21 +464,23 @@ function readJsonlForHour(year, month, day, hour, groupBy, filters) {
447
464
  const ts = new Date(entry.timestamp);
448
465
  if (getCSTHour(ts) !== hour) continue;
449
466
 
467
+ const actualModel = resolveActualModel(entry.model, entry);
468
+
450
469
  // Apply filters
451
470
  if (filters) {
452
471
  if (filters.toolType && entry.toolType !== filters.toolType) continue;
453
472
  if (filters.channel && entry.channel !== filters.channel) continue;
454
- if (filters.model && entry.model !== filters.model) continue;
473
+ if (filters.model && actualModel !== filters.model) continue;
455
474
  }
456
475
 
457
476
  let key;
458
- if (groupBy === 'model') key = entry.model || 'unknown';
477
+ if (groupBy === 'model') key = actualModel || 'unknown';
459
478
  else if (groupBy === 'channel') key = entry.channel || entry.channelId || 'unknown';
460
479
  else if (groupBy === 'toolType') key = entry.toolType || 'claude-code';
461
480
  else continue;
462
481
 
463
482
  if (!result[key]) result[key] = { tokens: { total: 0 }, cost: 0, requests: 0 };
464
- result[key].tokens.total += entry.tokens?.total || 0;
483
+ result[key].tokens.total += getTokenTotal(entry.tokens);
465
484
  result[key].cost += entry.cost || 0;
466
485
  result[key].requests += 1;
467
486
  }
@@ -493,21 +512,23 @@ function readJsonlForDay(year, month, day, groupBy, filters) {
493
512
  let entry;
494
513
  try { entry = JSON.parse(line); } catch { continue; }
495
514
 
515
+ const actualModel = resolveActualModel(entry.model, entry);
516
+
496
517
  // Apply filters
497
518
  if (filters) {
498
519
  if (filters.toolType && entry.toolType !== filters.toolType) continue;
499
520
  if (filters.channel && entry.channel !== filters.channel) continue;
500
- if (filters.model && entry.model !== filters.model) continue;
521
+ if (filters.model && actualModel !== filters.model) continue;
501
522
  }
502
523
 
503
524
  let key;
504
- if (groupBy === 'model') key = entry.model || 'unknown';
525
+ if (groupBy === 'model') key = actualModel || 'unknown';
505
526
  else if (groupBy === 'channel') key = entry.channel || entry.channelId || 'unknown';
506
527
  else if (groupBy === 'toolType') key = entry.toolType || 'claude-code';
507
528
  else continue;
508
529
 
509
530
  if (!result[key]) result[key] = { tokens: { total: 0 }, cost: 0, requests: 0 };
510
- result[key].tokens.total += entry.tokens?.total || 0;
531
+ result[key].tokens.total += getTokenTotal(entry.tokens);
511
532
  result[key].cost += entry.cost || 0;
512
533
  result[key].requests += 1;
513
534
  }
@@ -541,7 +562,7 @@ function filterProxyLogsForHour(logs, dateStr, hour, groupBy) {
541
562
  const result = {};
542
563
 
543
564
  for (const entry of logs) {
544
- if (!entry || entry.type === 'action') continue;
565
+ if (!entry || entry.type === 'action' || entry.status === 'error') continue;
545
566
 
546
567
  const ts = new Date(entry.timestamp || Date.now());
547
568
  if (Number.isNaN(ts.getTime())) continue;
@@ -549,11 +570,22 @@ function filterProxyLogsForHour(logs, dateStr, hour, groupBy) {
549
570
  const entryDate = getCSTDateStr(ts);
550
571
  if (entryDate !== dateStr || getCSTHour(ts) !== hour) continue;
551
572
 
573
+ const actualModel = resolveActualModel(entry.model, entry);
574
+ const normalizedTokens = normalizeUsageTokens(entry.source || mapSourceToToolType(entry.source), {
575
+ input: entry.inputTokens ?? entry.tokens?.input,
576
+ output: entry.outputTokens ?? entry.tokens?.output,
577
+ cacheCreation: entry.cacheCreation ?? entry.tokens?.cacheCreation,
578
+ cacheRead: entry.cacheRead ?? entry.tokens?.cacheRead,
579
+ cached: entry.cachedTokens ?? entry.tokens?.cached,
580
+ reasoning: entry.reasoningTokens ?? entry.tokens?.reasoning,
581
+ total: entry.totalTokens ?? entry.tokens?.total
582
+ });
583
+
552
584
  let key;
553
585
  if (groupBy === 'toolType') {
554
586
  key = mapSourceToToolType(entry.source);
555
587
  } else if (groupBy === 'model') {
556
- key = entry.model || 'unknown';
588
+ key = actualModel || 'unknown';
557
589
  } else if (groupBy === 'channel') {
558
590
  key = entry.channel || 'unknown';
559
591
  } else {
@@ -564,11 +596,7 @@ function filterProxyLogsForHour(logs, dateStr, hour, groupBy) {
564
596
  result[key] = { tokens: { total: 0 }, cost: 0, requests: 0 };
565
597
  }
566
598
 
567
- const totalTokens = entry.totalTokens ||
568
- entry.tokens?.total ||
569
- (entry.inputTokens || 0) + (entry.outputTokens || 0) + (entry.reasoningTokens || 0);
570
-
571
- result[key].tokens.total += totalTokens || 0;
599
+ result[key].tokens.total += normalizedTokens.total || 0;
572
600
  result[key].cost += entry.cost || 0;
573
601
  result[key].requests += 1;
574
602
  }
@@ -741,7 +769,7 @@ function aggregateDailyStatistics(dateStr) {
741
769
  }
742
770
 
743
771
  for (const [modelName, modelStats] of Object.entries(toolStats.models || {})) {
744
- const key = getScopedKey(aggregated.byModel, modelName, toolType);
772
+ const key = modelName || 'unknown';
745
773
  if (!aggregated.byModel[key]) {
746
774
  aggregated.byModel[key] = createEmptyEntry(toolType);
747
775
  }
@@ -835,6 +863,7 @@ function getAvailableFilters(startDate, endDate) {
835
863
  const toolTypes = new Set();
836
864
  const channels = new Set();
837
865
  const models = new Set();
866
+ const includeProxyLogs = startDate <= getCSTDateStr(Date.now()) && getCSTDateStr(Date.now()) <= endDate;
838
867
 
839
868
  const start = new Date(startDate + 'T00:00:00');
840
869
  const end = new Date(endDate + 'T00:00:00');
@@ -852,15 +881,31 @@ function getAvailableFilters(startDate, endDate) {
852
881
  if (!line.trim()) continue;
853
882
  let entry;
854
883
  try { entry = JSON.parse(line); } catch { continue; }
884
+ const actualModel = resolveActualModel(entry.model, entry);
855
885
  if (entry.toolType) toolTypes.add(entry.toolType);
856
886
  if (entry.channel) channels.add(entry.channel);
857
- if (entry.model) models.add(entry.model);
887
+ if (actualModel) models.add(actualModel);
858
888
  }
859
889
  } catch (err) {
860
890
  console.error('Failed to scan JSONL for filters:', err);
861
891
  }
862
892
  }
863
893
 
894
+ if (includeProxyLogs) {
895
+ for (const entry of loadProxyLogs()) {
896
+ if (!entry || entry.type === 'action' || entry.status === 'error') continue;
897
+ const ts = new Date(entry.timestamp || Date.now());
898
+ if (Number.isNaN(ts.getTime())) continue;
899
+ const entryDate = getCSTDateStr(ts);
900
+ if (entryDate < startDate || entryDate > endDate) continue;
901
+
902
+ toolTypes.add(mapSourceToToolType(entry.source));
903
+ if (entry.channel) channels.add(entry.channel);
904
+ const actualModel = resolveActualModel(entry.model, entry);
905
+ if (actualModel) models.add(actualModel);
906
+ }
907
+ }
908
+
864
909
  return {
865
910
  toolTypes: Array.from(toolTypes).sort(),
866
911
  channels: Array.from(channels).sort(),
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawn } = require('child_process');
6
+
7
+ const DEFAULT_PROJECT_ROOT = path.resolve(__dirname, '../../..');
8
+ const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', 'dist']);
9
+ const WATCHED_WEB_FILES = ['index.html', 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'vite.config.js'];
10
+ const WATCHED_WEB_DIRS = ['src', 'public'];
11
+
12
+ function pathExists(targetPath) {
13
+ return fs.existsSync(targetPath);
14
+ }
15
+
16
+ function getLatestMtimeMs(targetPath) {
17
+ if (!pathExists(targetPath)) {
18
+ return 0;
19
+ }
20
+
21
+ const stat = fs.statSync(targetPath);
22
+ if (!stat.isDirectory()) {
23
+ return stat.mtimeMs;
24
+ }
25
+
26
+ let latest = stat.mtimeMs;
27
+ for (const entry of fs.readdirSync(targetPath, { withFileTypes: true })) {
28
+ if (entry.isDirectory() && IGNORED_DIR_NAMES.has(entry.name)) {
29
+ continue;
30
+ }
31
+ const childPath = path.join(targetPath, entry.name);
32
+ latest = Math.max(latest, getLatestMtimeMs(childPath));
33
+ }
34
+ return latest;
35
+ }
36
+
37
+ function getWatchedWebSourceMtimeMs(sourceRoot) {
38
+ let latest = 0;
39
+ for (const fileName of WATCHED_WEB_FILES) {
40
+ latest = Math.max(latest, getLatestMtimeMs(path.join(sourceRoot, fileName)));
41
+ }
42
+ for (const dirName of WATCHED_WEB_DIRS) {
43
+ latest = Math.max(latest, getLatestMtimeMs(path.join(sourceRoot, dirName)));
44
+ }
45
+ return latest;
46
+ }
47
+
48
+ function inspectWebBuildState(options = {}) {
49
+ const projectRoot = options.projectRoot || DEFAULT_PROJECT_ROOT;
50
+ const sourceRoot = options.sourceRoot || path.join(projectRoot, 'src/web');
51
+ const distRoot = options.distRoot || path.join(projectRoot, 'dist/web');
52
+ const distIndexPath = path.join(distRoot, 'index.html');
53
+ const sourceAvailable = pathExists(sourceRoot) && pathExists(path.join(sourceRoot, 'package.json'));
54
+ const distExists = pathExists(distIndexPath);
55
+
56
+ if (!sourceAvailable) {
57
+ return {
58
+ projectRoot,
59
+ sourceRoot,
60
+ distRoot,
61
+ distIndexPath,
62
+ sourceAvailable: false,
63
+ distExists,
64
+ sourceMtimeMs: 0,
65
+ distMtimeMs: getLatestMtimeMs(distRoot),
66
+ needsBuild: false,
67
+ reason: 'source-unavailable'
68
+ };
69
+ }
70
+
71
+ const sourceMtimeMs = getWatchedWebSourceMtimeMs(sourceRoot);
72
+ const distMtimeMs = getLatestMtimeMs(distRoot);
73
+ const hasDistAssets = distExists && distMtimeMs > 0;
74
+ const isStale = hasDistAssets && sourceMtimeMs > distMtimeMs + 1;
75
+
76
+ return {
77
+ projectRoot,
78
+ sourceRoot,
79
+ distRoot,
80
+ distIndexPath,
81
+ sourceAvailable: true,
82
+ distExists,
83
+ sourceMtimeMs,
84
+ distMtimeMs,
85
+ needsBuild: !hasDistAssets || isStale,
86
+ reason: !hasDistAssets ? 'dist-missing' : (isStale ? 'dist-stale' : 'up-to-date')
87
+ };
88
+ }
89
+
90
+ function getNpmCommand() {
91
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
92
+ }
93
+
94
+ function runWebBuild(options = {}) {
95
+ const sourceRoot = options.sourceRoot || path.join(DEFAULT_PROJECT_ROOT, 'src/web');
96
+ const npmCommand = options.npmCommand || getNpmCommand();
97
+ const env = options.env || process.env;
98
+ const stdio = options.stdio || 'inherit';
99
+
100
+ return new Promise((resolve, reject) => {
101
+ const child = spawn(npmCommand, ['run', 'build'], {
102
+ cwd: sourceRoot,
103
+ env,
104
+ stdio
105
+ });
106
+
107
+ child.once('error', (error) => {
108
+ reject(error);
109
+ });
110
+
111
+ child.once('close', (code) => {
112
+ if (code === 0) {
113
+ resolve();
114
+ return;
115
+ }
116
+ reject(new Error(`Web UI build exited with code ${code}`));
117
+ });
118
+ });
119
+ }
120
+
121
+ async function ensureWebDistReady(options = {}) {
122
+ const initialState = options.state || inspectWebBuildState(options);
123
+ if (!initialState.needsBuild) {
124
+ return {
125
+ ...initialState,
126
+ built: false,
127
+ skipped: true,
128
+ previousReason: initialState.reason
129
+ };
130
+ }
131
+
132
+ const runBuild = options.runBuild || runWebBuild;
133
+
134
+ try {
135
+ await runBuild({
136
+ sourceRoot: initialState.sourceRoot,
137
+ distRoot: initialState.distRoot,
138
+ npmCommand: options.npmCommand || getNpmCommand(),
139
+ env: options.env,
140
+ stdio: options.stdio
141
+ });
142
+ } catch (error) {
143
+ const dependencyHint = pathExists(path.join(initialState.sourceRoot, 'node_modules'))
144
+ ? ''
145
+ : '(未检测到 src/web/node_modules,前端依赖可能尚未安装)';
146
+ throw new Error(`Web UI 静态资源构建失败: ${error.message}${dependencyHint}`);
147
+ }
148
+
149
+ const finalState = inspectWebBuildState({
150
+ projectRoot: initialState.projectRoot,
151
+ sourceRoot: initialState.sourceRoot,
152
+ distRoot: initialState.distRoot
153
+ });
154
+
155
+ return {
156
+ ...finalState,
157
+ built: true,
158
+ skipped: false,
159
+ previousReason: initialState.reason
160
+ };
161
+ }
162
+
163
+ module.exports = {
164
+ inspectWebBuildState,
165
+ ensureWebDistReady,
166
+ _test: {
167
+ getLatestMtimeMs,
168
+ getWatchedWebSourceMtimeMs,
169
+ getNpmCommand,
170
+ runWebBuild
171
+ }
172
+ };
@@ -504,11 +504,28 @@ function broadcastSchedulerState(source, schedulerState) {
504
504
  }
505
505
  }
506
506
 
507
+ function broadcastBrowserNotification(notification = {}) {
508
+ if (wss && wsClients.size > 0) {
509
+ const message = JSON.stringify({
510
+ type: 'browser-notification',
511
+ ...notification,
512
+ timestamp: notification.timestamp || Date.now()
513
+ });
514
+
515
+ wsClients.forEach(client => {
516
+ if (client.readyState === WebSocket.OPEN) {
517
+ client.send(message);
518
+ }
519
+ });
520
+ }
521
+ }
522
+
507
523
  module.exports = {
508
524
  startWebSocketServer,
509
525
  stopWebSocketServer,
510
526
  broadcastLog,
511
527
  clearAllLogs,
512
528
  broadcastProxyState,
513
- broadcastSchedulerState
529
+ broadcastSchedulerState,
530
+ broadcastBrowserNotification
514
531
  };