codexmate 0.0.28 → 0.0.30

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 (50) hide show
  1. package/cli/builtin-proxy.js +107 -2
  2. package/cli/config-bootstrap.js +30 -12
  3. package/cli/config-health.js +117 -1
  4. package/cli/local-bridge.js +324 -0
  5. package/cli/openai-bridge.js +195 -31
  6. package/cli.js +245 -28
  7. package/lib/cli-webhook.js +126 -0
  8. package/package.json +1 -1
  9. package/web-ui/app.js +28 -8
  10. package/web-ui/index.html +1 -0
  11. package/web-ui/logic.codex.mjs +13 -0
  12. package/web-ui/modules/app.computed.dashboard.mjs +25 -2
  13. package/web-ui/modules/app.computed.session.mjs +22 -17
  14. package/web-ui/modules/app.methods.claude-config.mjs +12 -2
  15. package/web-ui/modules/app.methods.codex-config.mjs +25 -0
  16. package/web-ui/modules/app.methods.index.mjs +2 -0
  17. package/web-ui/modules/app.methods.navigation.mjs +39 -8
  18. package/web-ui/modules/app.methods.providers.mjs +125 -8
  19. package/web-ui/modules/app.methods.session-actions.mjs +1 -1
  20. package/web-ui/modules/app.methods.session-browser.mjs +1 -1
  21. package/web-ui/modules/app.methods.session-trash.mjs +3 -4
  22. package/web-ui/modules/app.methods.startup-claude.mjs +1 -0
  23. package/web-ui/modules/app.methods.webhook.mjs +79 -0
  24. package/web-ui/modules/i18n.dict.mjs +1109 -72
  25. package/web-ui/modules/i18n.mjs +9 -3
  26. package/web-ui/modules/skills.methods.mjs +1 -0
  27. package/web-ui/partials/index/layout-header.html +25 -0
  28. package/web-ui/partials/index/modals-basic.html +0 -3
  29. package/web-ui/partials/index/panel-config-claude.html +8 -2
  30. package/web-ui/partials/index/panel-config-codex.html +28 -3
  31. package/web-ui/partials/index/panel-dashboard.html +33 -0
  32. package/web-ui/partials/index/panel-market.html +3 -3
  33. package/web-ui/partials/index/panel-plugins.html +2 -2
  34. package/web-ui/partials/index/panel-sessions.html +1 -9
  35. package/web-ui/partials/index/panel-settings.html +71 -134
  36. package/web-ui/partials/index/panel-trash.html +88 -0
  37. package/web-ui/session-helpers.mjs +20 -2
  38. package/web-ui/styles/dashboard.css +132 -0
  39. package/web-ui/styles/docs-panel.css +63 -39
  40. package/web-ui/styles/layout-shell.css +54 -34
  41. package/web-ui/styles/plugins-panel.css +121 -80
  42. package/web-ui/styles/sessions-list.css +41 -43
  43. package/web-ui/styles/sessions-preview.css +34 -38
  44. package/web-ui/styles/sessions-toolbar-trash.css +31 -27
  45. package/web-ui/styles/settings-panel.css +197 -33
  46. package/web-ui/styles/skills-list.css +12 -10
  47. package/web-ui/styles/skills-market.css +67 -44
  48. package/web-ui/styles/trash-panel.css +90 -0
  49. package/web-ui/styles/webhook.css +81 -0
  50. package/web-ui/styles.css +2 -0
@@ -127,6 +127,42 @@ function createBuiltinProxyRuntimeController(deps = {}) {
127
127
  return false;
128
128
  }
129
129
 
130
+ function isTransientNetworkError(error) {
131
+ const text = String(error || '').trim();
132
+ if (!text) return false;
133
+ if (/socket hang up/i.test(text)) return true;
134
+ if (/ECONNRESET|ECONNREFUSED|EPIPE|EPROTO|ETIMEDOUT/i.test(text)) return true;
135
+ if (/EAI_AGAIN/i.test(text)) return true;
136
+ if (/UND_ERR_SOCKET/i.test(text)) return true;
137
+ if (/disconnected before|secure tls|tls handshake/i.test(text)) return true;
138
+ return false;
139
+ }
140
+
141
+ const TRANSIENT_RETRY_DELAYS_MS = [200, 600];
142
+
143
+ async function retryTransientRequest(executor) {
144
+ let lastResult = null;
145
+ for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt += 1) {
146
+ if (attempt > 0) {
147
+ const delay = TRANSIENT_RETRY_DELAYS_MS[attempt - 1];
148
+ // eslint-disable-next-line no-await-in-loop
149
+ await new Promise((r) => {
150
+ const t = setTimeout(r, delay);
151
+ if (typeof t.unref === 'function') t.unref();
152
+ });
153
+ }
154
+ // eslint-disable-next-line no-await-in-loop
155
+ const result = await executor(attempt);
156
+ lastResult = result;
157
+ if (!result) return result;
158
+ if (result.ok) return result;
159
+ if (result.retry) return result;
160
+ if (result.status && result.status > 0) return result;
161
+ if (!isTransientNetworkError(result.error)) return result;
162
+ }
163
+ return lastResult;
164
+ }
165
+
130
166
  function proxyRequestJson(targetUrl, options = {}) {
131
167
  const parsed = new URL(targetUrl);
132
168
  const transport = parsed.protocol === 'https:' ? https : http;
@@ -206,7 +242,7 @@ function createBuiltinProxyRuntimeController(deps = {}) {
206
242
  }
207
243
  let lastResult = null;
208
244
  for (let index = 0; index < urls.length; index += 1) {
209
- const result = await proxyRequestJson(urls[index], options);
245
+ const result = await retryTransientRequest(() => proxyRequestJson(urls[index], options));
210
246
  lastResult = result;
211
247
  if (!result.ok) {
212
248
  return result;
@@ -702,9 +738,34 @@ function createBuiltinProxyRuntimeController(deps = {}) {
702
738
  }
703
739
  }
704
740
 
741
+ function stopChatStreamHeartbeat(state) {
742
+ if (!state || !state.heartbeatTimer) return;
743
+ clearInterval(state.heartbeatTimer);
744
+ state.heartbeatTimer = null;
745
+ }
746
+
747
+ function startChatStreamHeartbeat(state) {
748
+ if (!state || state.heartbeatTimer) return;
749
+ const timer = setInterval(() => {
750
+ if (state.finished) {
751
+ stopChatStreamHeartbeat(state);
752
+ return;
753
+ }
754
+ const target = state.res;
755
+ if (!target || target.writableEnded || target.destroyed) {
756
+ stopChatStreamHeartbeat(state);
757
+ return;
758
+ }
759
+ try { target.write(': keepalive\n\n'); } catch (_) {}
760
+ }, 15000);
761
+ if (typeof timer.unref === 'function') timer.unref();
762
+ state.heartbeatTimer = timer;
763
+ }
764
+
705
765
  function finishChatStreamResponsesSse(state) {
706
766
  if (state.finished) return;
707
767
  state.finished = true;
768
+ stopChatStreamHeartbeat(state);
708
769
 
709
770
  if (state.messageItem) {
710
771
  const outputIndex = state.output.indexOf(state.messageItem);
@@ -759,6 +820,22 @@ function createBuiltinProxyRuntimeController(deps = {}) {
759
820
  state.res.end();
760
821
  }
761
822
 
823
+ function failResponsesSseRaw(res, message) {
824
+ if (!res || res.writableEnded || res.destroyed) return;
825
+ try {
826
+ writeSse(res, 'response.failed', { type: 'response.failed', error: message || 'upstream stream failed' });
827
+ writeSse(res, 'done', '[DONE]');
828
+ res.end();
829
+ } catch (_) {}
830
+ }
831
+
832
+ function failChatStreamResponsesSse(state, message) {
833
+ if (!state || state.finished) return;
834
+ state.finished = true;
835
+ stopChatStreamHeartbeat(state);
836
+ failResponsesSseRaw(state.res, message);
837
+ }
838
+
762
839
  function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
763
840
  const parsed = new URL(targetUrl);
764
841
  const transport = parsed.protocol === 'https:' ? https : http;
@@ -796,6 +873,29 @@ function createBuiltinProxyRuntimeController(deps = {}) {
796
873
  const status = upstreamRes.statusCode || 0;
797
874
  const chunks = [];
798
875
  const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || '');
876
+ let streamState = null;
877
+
878
+ const handleAbort = (reason) => {
879
+ if (settled) return;
880
+ if (streamState) {
881
+ failChatStreamResponsesSse(streamState, reason);
882
+ finish({ ok: true });
883
+ return;
884
+ }
885
+ if (res.headersSent) {
886
+ failResponsesSseRaw(res, reason);
887
+ finish({ ok: true });
888
+ return;
889
+ }
890
+ finish({
891
+ ok: false,
892
+ status,
893
+ error: reason,
894
+ bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : ''
895
+ });
896
+ };
897
+ upstreamRes.on('error', (err) => handleAbort(err && err.message ? err.message : 'upstream stream failed'));
898
+ upstreamRes.on('aborted', () => handleAbort('upstream stream aborted'));
799
899
 
800
900
  if (status === 404 || status === 405) {
801
901
  upstreamRes.on('data', (chunk) => chunk && chunks.push(chunk));
@@ -851,6 +951,11 @@ function createBuiltinProxyRuntimeController(deps = {}) {
851
951
  return sequence;
852
952
  }
853
953
  };
954
+ streamState = state;
955
+ startChatStreamHeartbeat(state);
956
+ if (typeof res.on === 'function') {
957
+ res.on('close', () => stopChatStreamHeartbeat(state));
958
+ }
854
959
  writeSse(res, 'response.created', {
855
960
  type: 'response.created',
856
961
  response: {
@@ -914,7 +1019,7 @@ function createBuiltinProxyRuntimeController(deps = {}) {
914
1019
  }
915
1020
  let lastResult = null;
916
1021
  for (const url of urls) {
917
- const result = await streamChatCompletionsAsResponsesSse(url, options);
1022
+ const result = await retryTransientRequest(() => streamChatCompletionsAsResponsesSse(url, options));
918
1023
  lastResult = result;
919
1024
  if (result && result.retry) continue;
920
1025
  return result;
@@ -30,6 +30,7 @@ function createConfigBootstrapController(deps = {}) {
30
30
  DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT,
31
31
  CODEXMATE_MANAGED_MARKER,
32
32
  BUILTIN_PROXY_PROVIDER_NAME,
33
+ BUILTIN_LOCAL_PROVIDER_NAME,
33
34
  EMPTY_CONFIG_FALLBACK_TEMPLATE
34
35
  } = deps;
35
36
 
@@ -58,6 +59,7 @@ function createConfigBootstrapController(deps = {}) {
58
59
  if (!Array.isArray(DEFAULT_MODELS)) throw new Error('createConfigBootstrapController 缺少 DEFAULT_MODELS');
59
60
  if (!CODEXMATE_MANAGED_MARKER) throw new Error('createConfigBootstrapController 缺少 CODEXMATE_MANAGED_MARKER');
60
61
  if (!BUILTIN_PROXY_PROVIDER_NAME) throw new Error('createConfigBootstrapController 缺少 BUILTIN_PROXY_PROVIDER_NAME');
62
+ if (!BUILTIN_LOCAL_PROVIDER_NAME) throw new Error('createConfigBootstrapController 缺少 BUILTIN_LOCAL_PROVIDER_NAME');
61
63
  if (typeof EMPTY_CONFIG_FALLBACK_TEMPLATE !== 'string') throw new Error('createConfigBootstrapController 缺少 EMPTY_CONFIG_FALLBACK_TEMPLATE');
62
64
 
63
65
  let initNotice = '';
@@ -118,17 +120,18 @@ function createConfigBootstrapController(deps = {}) {
118
120
  return `${CODEXMATE_MANAGED_MARKER}
119
121
  # codexmate-initialized-at: ${initializedAt}
120
122
 
121
- model_provider = "openai"
123
+ model_provider = "local"
122
124
  model = "${defaultModel}"
123
125
  model_context_window = ${DEFAULT_MODEL_CONTEXT_WINDOW}
124
126
  model_auto_compact_token_limit = ${DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT}
125
127
 
126
- [model_providers.openai]
127
- name = "openai"
128
- base_url = "https://api.openai.com/v1"
128
+ [model_providers.local]
129
+ name = "local"
130
+ base_url = "http://127.0.0.1:3737/bridge/local/v1"
129
131
  wire_api = "responses"
130
- requires_openai_auth = false
131
- preferred_auth_method = ""
132
+ requires_openai_auth = true
133
+ preferred_auth_method = "codexmate"
134
+ codexmate_bridge = "local"
132
135
  request_max_retries = 4
133
136
  stream_max_retries = 10
134
137
  stream_idle_timeout_ms = 300000
@@ -145,9 +148,8 @@ stream_idle_timeout_ms = 300000
145
148
  const currentProvider = typeof safeConfig.model_provider === 'string' ? safeConfig.model_provider.trim() : '';
146
149
  const hasRemovedBuiltin = !!(providers && providers[BUILTIN_PROXY_PROVIDER_NAME]);
147
150
  const currentIsRemovedBuiltin = currentProvider === BUILTIN_PROXY_PROVIDER_NAME;
148
- const currentIsRemovedVirtualLocal = currentProvider === 'local' && !(providers && isPlainObject(providers.local));
149
151
 
150
- if (!hasRemovedBuiltin && !currentIsRemovedBuiltin && !currentIsRemovedVirtualLocal) {
152
+ if (!hasRemovedBuiltin && !currentIsRemovedBuiltin) {
151
153
  return safeConfig;
152
154
  }
153
155
 
@@ -163,11 +165,26 @@ stream_idle_timeout_ms = 300000
163
165
  return {
164
166
  ...safeConfig,
165
167
  model_providers: nextProviders,
166
- model_provider: (currentIsRemovedBuiltin || currentIsRemovedVirtualLocal) ? fallbackProvider : safeConfig.model_provider,
167
- model: (currentIsRemovedBuiltin || currentIsRemovedVirtualLocal) ? fallbackModel : safeConfig.model
168
+ model_provider: currentIsRemovedBuiltin ? fallbackProvider : safeConfig.model_provider,
169
+ model: currentIsRemovedBuiltin ? fallbackModel : safeConfig.model
168
170
  };
169
171
  }
170
172
 
173
+ function ensureLocalProviderSection() {
174
+ if (!fs.existsSync(CONFIG_FILE)) return;
175
+ let content;
176
+ try {
177
+ content = fs.readFileSync(CONFIG_FILE, 'utf-8');
178
+ } catch (e) {
179
+ return;
180
+ }
181
+ // Check if [model_providers.local] section already exists
182
+ if (/\[model_providers\.local\]/.test(content)) return;
183
+
184
+ const localSection = `\n[model_providers.local]\nname = "local"\nbase_url = "http://127.0.0.1:3737/bridge/local/v1"\nwire_api = "responses"\nrequires_openai_auth = true\npreferred_auth_method = "codexmate"\ncodexmate_bridge = "local"\nrequest_max_retries = 4\nstream_max_retries = 10\nstream_idle_timeout_ms = 300000\n`;
185
+ fs.appendFileSync(CONFIG_FILE, localSection, 'utf-8');
186
+ }
187
+
171
188
  function readConfigOrVirtualDefault() {
172
189
  if (fs.existsSync(CONFIG_FILE)) {
173
190
  try {
@@ -260,7 +277,7 @@ stream_idle_timeout_ms = 300000
260
277
  ensureConfigDir();
261
278
 
262
279
  const initializedAt = new Date().toISOString();
263
- const defaultProvider = 'openai';
280
+ const defaultProvider = 'local';
264
281
  const defaultModel = DEFAULT_MODELS[0] || 'gpt-4';
265
282
  const forceResetExistingConfig = process.env.CODEXMATE_FORCE_RESET_EXISTING_CONFIG === '1';
266
283
  const mark = readJsonFile(INIT_MARK_FILE, null);
@@ -273,6 +290,7 @@ stream_idle_timeout_ms = 300000
273
290
  initNotice = '检测到配置缺失,已自动重建默认配置。';
274
291
  return { notice: initNotice };
275
292
  }
293
+ ensureLocalProviderSection();
276
294
  ensureSupportFiles(defaultProvider, defaultModel);
277
295
  return { notice: '' };
278
296
  }
@@ -338,7 +356,7 @@ stream_idle_timeout_ms = 300000
338
356
  function resetConfigToDefault() {
339
357
  ensureConfigDir();
340
358
  const initializedAt = new Date().toISOString();
341
- const defaultProvider = 'openai';
359
+ const defaultProvider = 'local';
342
360
  const defaultModel = DEFAULT_MODELS[0] || 'gpt-4';
343
361
 
344
362
  let backupFile = '';
@@ -332,7 +332,123 @@ async function buildConfigHealthReport(params = {}, deps = {}) {
332
332
  };
333
333
  }
334
334
 
335
+ async function buildAllProvidersHealthReport(params = {}, deps = {}) {
336
+ const {
337
+ readConfigOrVirtualDefault,
338
+ readCurrentModels,
339
+ probeJsonPost: probeJsonPostDep
340
+ } = deps;
341
+
342
+ if (typeof readConfigOrVirtualDefault !== 'function') {
343
+ throw new Error('buildAllProvidersHealthReport 缺少 readConfigOrVirtualDefault 依赖');
344
+ }
345
+
346
+ const status = readConfigOrVirtualDefault();
347
+ const config = status.config || {};
348
+ const providerEntries = config.model_providers && typeof config.model_providers === 'object'
349
+ ? Object.entries(config.model_providers)
350
+ : [];
351
+ const currentProvider = typeof config.model_provider === 'string' ? config.model_provider.trim() : '';
352
+ const currentModels = typeof readCurrentModels === 'function' ? readCurrentModels() : {};
353
+
354
+ if (!providerEntries.length) {
355
+ return {
356
+ ok: true,
357
+ currentProvider,
358
+ providers: [],
359
+ summary: { total: 0, green: 0, yellow: 0, red: 0 }
360
+ };
361
+ }
362
+
363
+ const remote = !!(params && params.remote);
364
+ const timeoutMs = Number.isFinite(params && params.timeoutMs) ? Number(params.timeoutMs) : DEFAULT_TIMEOUT_MS;
365
+
366
+ const entries = providerEntries.map(([name, provider]) => {
367
+ const baseUrl = typeof provider.base_url === 'string' ? provider.base_url.trim() : '';
368
+ const requiresAuth = provider.requires_openai_auth !== false;
369
+ const apiKey = typeof provider.preferred_auth_method === 'string'
370
+ ? provider.preferred_auth_method.trim() : '';
371
+ const modelName = currentModels[name] || (name === currentProvider ? (config.model || '').trim() : '');
372
+
373
+ const checks = {
374
+ baseUrlValid: isValidHttpUrl(baseUrl),
375
+ apiKeyPresent: !requiresAuth || !!apiKey
376
+ };
377
+
378
+ const issues = [];
379
+ if (!baseUrl) {
380
+ issues.push({ code: 'base-url-empty', message: `${name} 的 base_url 为空`, suggestion: '设置 base_url' });
381
+ } else if (!checks.baseUrlValid) {
382
+ issues.push({ code: 'base-url-invalid', message: `${name} 的 base_url 无效`, suggestion: '设置为 http/https 完整 URL' });
383
+ }
384
+ if (!checks.apiKeyPresent) {
385
+ issues.push({ code: 'api-key-missing', message: `${name} 未配置 API Key`, suggestion: '设置 preferred_auth_method' });
386
+ }
387
+
388
+ return { name, provider, baseUrl, modelName, checks, issues };
389
+ });
390
+
391
+ if (remote) {
392
+ const remotePromises = entries.map(async (entry) => {
393
+ if (!entry.checks.baseUrlValid || !entry.modelName) {
394
+ entry.remote = null;
395
+ return;
396
+ }
397
+ try {
398
+ const report = await runRemoteHealthCheck(entry.name, entry.provider, entry.modelName, {
399
+ timeoutMs,
400
+ probeJsonPost: probeJsonPostDep
401
+ });
402
+ entry.remote = report.remote;
403
+ entry.issues.push(...report.issues);
404
+ } catch (e) {
405
+ entry.remote = null;
406
+ entry.issues.push({
407
+ code: 'remote-probe-error',
408
+ message: `${entry.name} 远程探测异常: ${e.message}`,
409
+ suggestion: '检查网络连接与 endpoint'
410
+ });
411
+ }
412
+ });
413
+ await Promise.allSettled(remotePromises);
414
+ }
415
+
416
+ const providers = entries.map((entry) => {
417
+ const hasConfigIssue = !entry.checks.baseUrlValid || !entry.checks.apiKeyPresent;
418
+ const remoteFailed = entry.remote && !entry.remote.ok;
419
+ let statusValue = 'green';
420
+ if (remoteFailed) {
421
+ statusValue = 'red';
422
+ } else if (hasConfigIssue) {
423
+ statusValue = 'yellow';
424
+ }
425
+ return {
426
+ provider: entry.name,
427
+ isCurrent: entry.name === currentProvider,
428
+ status: statusValue,
429
+ checks: entry.checks,
430
+ remote: entry.remote || null,
431
+ issues: entry.issues
432
+ };
433
+ });
434
+
435
+ const summary = {
436
+ total: providers.length,
437
+ green: providers.filter((p) => p.status === 'green').length,
438
+ yellow: providers.filter((p) => p.status === 'yellow').length,
439
+ red: providers.filter((p) => p.status === 'red').length
440
+ };
441
+
442
+ return {
443
+ ok: summary.red === 0 && summary.yellow === 0,
444
+ currentProvider,
445
+ providers,
446
+ summary
447
+ };
448
+ }
449
+
335
450
  module.exports = {
336
451
  runRemoteHealthCheck,
337
- buildConfigHealthReport
452
+ buildConfigHealthReport,
453
+ buildAllProvidersHealthReport
338
454
  };