From 7628878ae9d42abc4969a53ec24d9ea984275dc1 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sun, 12 Jul 2026 08:54:33 +0000 Subject: [PATCH 01/13] fix(openai-bridge): avoid duplicate error headers --- cli/openai-bridge.js | 14 +++- .../openai-bridge-upstream-responses.test.mjs | 74 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/cli/openai-bridge.js b/cli/openai-bridge.js index f56e3101..00e8753e 100644 --- a/cli/openai-bridge.js +++ b/cli/openai-bridge.js @@ -514,8 +514,20 @@ function createOpenaiBridgeHttpHandler(options = {}) { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(ensureResponseMetadata(responsesPayload))); } catch (e) { + if (res.writableEnded || res.destroyed) { + return; + } + const message = e && e.message ? e.message : 'Internal Error'; + if (res.headersSent) { + try { + res.end(); + } catch (_) { + // Headers are already committed; avoid surfacing a secondary write failure. + } + return; + } res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify({ error: e && e.message ? e.message : 'Internal Error' })); + res.end(JSON.stringify({ error: message })); } })(); diff --git a/tests/unit/openai-bridge-upstream-responses.test.mjs b/tests/unit/openai-bridge-upstream-responses.test.mjs index f207671c..f7088a8a 100644 --- a/tests/unit/openai-bridge-upstream-responses.test.mjs +++ b/tests/unit/openai-bridge-upstream-responses.test.mjs @@ -1609,3 +1609,77 @@ test('openai-bridge maps reasoning effort when converting to upstream chat compl await upstream.close(); await rm(tmpDir, { recursive: true, force: true }); }); + +test('openai-bridge does not write 500 headers after response headers were already sent', async () => { + const upstream = http.createServer((req, res) => { + if (req.url === '/v1/chat/completions' && req.method === 'POST') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'chatcmpl_headers_sent_regression', + model: 'gpt-test', + choices: [{ message: { role: 'assistant', content: 'ok' } }] + })); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); + const { port: upstreamPort } = await listen(upstream); + + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'codexmate-bridge-test-')); + const settingsFile = path.join(tmpDir, 'bridge.json'); + await writeFile(settingsFile, JSON.stringify({ + version: 1, + providers: { + test: { baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, apiKey: 'sk-upstream' } + } + }), 'utf-8'); + + let uncaught = null; + const onUncaught = (err) => { uncaught = err; }; + process.once('uncaughtException', onUncaught); + + const handler = createOpenaiBridgeHttpHandler({ settingsFile, expectedToken: 'codexmate' }); + const bridge = http.createServer((req, res) => { + const originalWriteHead = res.writeHead.bind(res); + let wroteSuccessHeader = false; + res.writeHead = function patchedWriteHead(statusCode, headers) { + if (statusCode === 200) wroteSuccessHeader = true; + if (wroteSuccessHeader && statusCode === 500) { + throw new Error('regression: attempted second writeHead after headers sent'); + } + return originalWriteHead(statusCode, headers); + }; + const originalEnd = res.end.bind(res); + res.end = function patchedEnd(chunk, encoding, cb) { + if (wroteSuccessHeader && !res.writableEnded) { + originalEnd(chunk, encoding, cb); + throw new Error('simulated post-header write failure'); + } + return originalEnd(chunk, encoding, cb); + }; + if (!handler(req, res)) { + res.statusCode = 404; + res.end('not handled'); + } + }); + const { port: bridgePort } = await listen(bridge); + + const resp = await requestText(`http://127.0.0.1:${bridgePort}/bridge/openai/test/v1/responses`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer codexmate' + }, + body: { model: 'gpt-test', input: 'ping' } + }); + + assert.equal(resp.status, 200); + assert.equal(uncaught, null); + + process.removeListener('uncaughtException', onUncaught); + await bridge.close(); + await upstream.close(); + await rm(tmpDir, { recursive: true, force: true }); +}); From 25d4f2782bc0530e877a282aa2b374840d090701 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sun, 12 Jul 2026 09:18:51 +0000 Subject: [PATCH 02/13] fix(openai-bridge): close failed post-header responses --- cli/openai-bridge.js | 5 +- .../openai-bridge-upstream-responses.test.mjs | 69 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/cli/openai-bridge.js b/cli/openai-bridge.js index 00e8753e..20892fd2 100644 --- a/cli/openai-bridge.js +++ b/cli/openai-bridge.js @@ -522,7 +522,10 @@ function createOpenaiBridgeHttpHandler(options = {}) { try { res.end(); } catch (_) { - // Headers are already committed; avoid surfacing a secondary write failure. + // Headers are already committed. Close the socket instead of leaving the client waiting forever. + if (!res.destroyed && typeof res.destroy === 'function') { + res.destroy(e); + } } return; } diff --git a/tests/unit/openai-bridge-upstream-responses.test.mjs b/tests/unit/openai-bridge-upstream-responses.test.mjs index f7088a8a..6fddfb1a 100644 --- a/tests/unit/openai-bridge-upstream-responses.test.mjs +++ b/tests/unit/openai-bridge-upstream-responses.test.mjs @@ -1683,3 +1683,72 @@ test('openai-bridge does not write 500 headers after response headers were alrea await upstream.close(); await rm(tmpDir, { recursive: true, force: true }); }); + +test('openai-bridge destroys response when post-header end fails before completion', async () => { + const upstream = http.createServer((req, res) => { + if (req.url === '/v1/chat/completions' && req.method === 'POST') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'chatcmpl_destroy_on_end_failure', + model: 'gpt-test', + choices: [{ message: { role: 'assistant', content: 'ok' } }] + })); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); + const { port: upstreamPort } = await listen(upstream); + + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'codexmate-bridge-test-')); + const settingsFile = path.join(tmpDir, 'bridge.json'); + await writeFile(settingsFile, JSON.stringify({ + version: 1, + providers: { + test: { baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, apiKey: '***' } + } + }), 'utf-8'); + + const handler = createOpenaiBridgeHttpHandler({ settingsFile, expectedToken: 'codexmate' }); + let destroyCalled = false; + const bridge = http.createServer((req, res) => { + const originalWriteHead = res.writeHead.bind(res); + let wroteSuccessHeader = false; + res.writeHead = function patchedWriteHead(statusCode, headers) { + if (statusCode === 200) wroteSuccessHeader = true; + return originalWriteHead(statusCode, headers); + }; + res.end = function patchedEnd() { + if (wroteSuccessHeader) { + throw new Error('simulated end failure before completion'); + } + }; + const originalDestroy = res.destroy.bind(res); + res.destroy = function patchedDestroy(err) { + destroyCalled = Boolean(err); + return originalDestroy(err); + }; + if (!handler(req, res)) { + res.statusCode = 404; + res.end('not handled'); + } + }); + const { port: bridgePort } = await listen(bridge); + + await assert.rejects( + requestText(`http://127.0.0.1:${bridgePort}/bridge/openai/test/v1/responses`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': '***' + }, + body: { model: 'gpt-test', input: 'ping' } + }) + ); + assert.equal(destroyCalled, true); + + await bridge.close(); + await upstream.close(); + await rm(tmpDir, { recursive: true, force: true }); +}); From 563d3bf8bf34f639485b55192ab3ea301c0aac7a Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sun, 12 Jul 2026 10:32:02 +0000 Subject: [PATCH 03/13] fix(openai-bridge): retry transient HTTP statuses --- cli/openai-bridge-retry.js | 11 +++- .../openai-bridge-upstream-responses.test.mjs | 55 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/cli/openai-bridge-retry.js b/cli/openai-bridge-retry.js index e2604b8c..7761f827 100644 --- a/cli/openai-bridge-retry.js +++ b/cli/openai-bridge-retry.js @@ -11,6 +11,11 @@ function normalizeBridgeMaxRetries(value, fallback = DEFAULT_BRIDGE_MAX_RETRIES) return Math.min(MAX_BRIDGE_MAX_RETRIES, Math.max(MIN_BRIDGE_MAX_RETRIES, Math.floor(base))); } +function isTransientHttpStatus(status) { + const code = Number(status); + return code === 408 || code === 409 || code === 425 || code === 429 || code === 500 || code === 502 || code === 503 || code === 504 || code === 520 || code === 521 || code === 522 || code === 523 || code === 524; +} + function isTransientNetworkError(error) { const text = String(error || '').trim(); if (!text) return false; @@ -43,9 +48,12 @@ async function retryTransientRequest(executor, options = {}) { const result = await executor(attempt); lastResult = result; if (!result) return result; + if (result.status && result.status > 0) { + if (!isTransientHttpStatus(result.status)) return result; + continue; + } if (result.ok) return result; if (result.retry) return result; - if (result.status && result.status > 0) return result; if (!isTransientNetworkError(result.error)) return result; } return lastResult; @@ -57,6 +65,7 @@ module.exports = { MAX_BRIDGE_MAX_RETRIES, normalizeBridgeMaxRetries, isTransientNetworkError, + isTransientHttpStatus, getTransientRetryDelayMs, retryTransientRequest }; diff --git a/tests/unit/openai-bridge-upstream-responses.test.mjs b/tests/unit/openai-bridge-upstream-responses.test.mjs index 6fddfb1a..fa6116fd 100644 --- a/tests/unit/openai-bridge-upstream-responses.test.mjs +++ b/tests/unit/openai-bridge-upstream-responses.test.mjs @@ -140,6 +140,61 @@ test('openai-bridge honors provider maxRetries for transient upstream failures', await rm(tmpDir, { recursive: true, force: true }); }); + +test('openai-bridge retries transient upstream 524 responses', async () => { + let hitCount = 0; + const upstream = http.createServer((req, res) => { + if (req.url === '/v1/models' && req.method === 'GET') { + hitCount += 1; + if (hitCount < 3) { + res.writeHead(524, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'gateway timeout' })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ object: 'list', data: [{ id: 'gpt-test' }] })); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); + const { port: upstreamPort } = await listen(upstream); + + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'codexmate-bridge-test-')); + const settingsFile = path.join(tmpDir, 'bridge.json'); + await writeFile(settingsFile, JSON.stringify({ + version: 1, + providers: { + test: { + baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, + apiKey: 'sk-upstream', + maxRetries: 3 + } + } + }), 'utf-8'); + + const handler = createOpenaiBridgeHttpHandler({ settingsFile, expectedToken: 'codexmate' }); + const bridge = http.createServer((req, res) => { + if (!handler(req, res)) { + res.statusCode = 404; + res.end('not handled'); + } + }); + const { port: bridgePort } = await listen(bridge); + + const resp = await requestText(`http://127.0.0.1:${bridgePort}/bridge/openai/test/v1/models`, { + headers: { Authorization: 'Bearer codexmate' } + }); + + assert.equal(resp.status, 200); + assert.equal(hitCount, 3); + assert.deepEqual(JSON.parse(resp.text), { object: 'list', data: [{ id: 'gpt-test' }] }); + + await bridge.close(); + await upstream.close(); + await rm(tmpDir, { recursive: true, force: true }); +}); + test('openai-bridge forces streaming Codex Responses requests through chat completions conversion', async () => { let responsesHit = false; let chatHit = false; From 43722181659cab16103ec867bfd001e4019fcd06 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sun, 12 Jul 2026 11:58:33 +0000 Subject: [PATCH 04/13] fix(openai-bridge): retry transient responses streams --- .../openai-bridge-upstream-responses.test.mjs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/unit/openai-bridge-upstream-responses.test.mjs b/tests/unit/openai-bridge-upstream-responses.test.mjs index fa6116fd..872c7115 100644 --- a/tests/unit/openai-bridge-upstream-responses.test.mjs +++ b/tests/unit/openai-bridge-upstream-responses.test.mjs @@ -195,6 +195,72 @@ test('openai-bridge retries transient upstream 524 responses', async () => { await rm(tmpDir, { recursive: true, force: true }); }); + +test('openai-bridge retries transient upstream 524 responses for streaming Responses conversion', async () => { + let chatHitCount = 0; + const upstream = http.createServer((req, res) => { + if (req.url === '/v1/chat/completions' && req.method === 'POST') { + chatHitCount += 1; + if (chatHitCount < 3) { + res.writeHead(524, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'gateway timeout' })); + return; + } + res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8' }); + res.write('data: {"id":"chatcmpl_retry_524_stream","model":"gpt-test","choices":[{"delta":{"content":"ok"}}]}\n\n'); + res.end('data: [DONE]\n\n'); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); + const { port: upstreamPort } = await listen(upstream); + + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'codexmate-bridge-test-')); + const settingsFile = path.join(tmpDir, 'bridge.json'); + await writeFile(settingsFile, JSON.stringify({ + version: 1, + providers: { + test: { + baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, + apiKey: 'sk-upstream', + maxRetries: 3 + } + } + }), 'utf-8'); + + const handler = createOpenaiBridgeHttpHandler({ settingsFile, expectedToken: 'codexmate', streamTimeoutMs: 1000 }); + const bridge = http.createServer((req, res) => { + if (!handler(req, res)) { + res.statusCode = 404; + res.end('not handled'); + } + }); + const { port: bridgePort } = await listen(bridge); + + const sse = await requestText(`http://127.0.0.1:${bridgePort}/bridge/openai/test/v1/responses`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + 'Authorization': 'Bearer codexmate', + 'Originator': 'codex-tui' + }, + body: { model: 'gpt-test', input: 'ping', stream: true } + }); + + assert.equal(sse.status, 200); + assert.equal(chatHitCount, 3); + assert.match(sse.headers['content-type'], /text\/event-stream/i); + assert.match(sse.text, /response\.output_text\.delta/); + assert.match(sse.text, /ok/); + assert.doesNotMatch(sse.text, /gateway timeout/); + + await bridge.close(); + await upstream.close(); + await rm(tmpDir, { recursive: true, force: true }); +}); + test('openai-bridge forces streaming Codex Responses requests through chat completions conversion', async () => { let responsesHit = false; let chatHit = false; From 6431495eedb207d8025a4f851a72acb8580e78e9 Mon Sep 17 00:00:00 2001 From: ymkiux Date: Sun, 12 Jul 2026 20:32:21 +0800 Subject: [PATCH 05/13] fix(web-ui): expand main area when sidebar collapses --- web-ui/styles/layout-shell.css | 1 + 1 file changed, 1 insertion(+) diff --git a/web-ui/styles/layout-shell.css b/web-ui/styles/layout-shell.css index ca606ff9..99b8b623 100644 --- a/web-ui/styles/layout-shell.css +++ b/web-ui/styles/layout-shell.css @@ -40,6 +40,7 @@ body::after { .app-shell.sidebar-collapsed { --side-rail-width: var(--side-rail-collapsed-width, 136px); + grid-template-columns: var(--side-rail-collapsed-width, 136px) minmax(0, 1fr); } .app-shell.standalone { From 187a747035815872618574d1002963361ba8c241 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sun, 12 Jul 2026 14:59:24 +0000 Subject: [PATCH 06/13] fix(openai-bridge): hide retry counts and keep retrying --- cli/openai-bridge-retry.js | 14 ++--- cli/openai-bridge.js | 13 ++-- .../openai-bridge-upstream-responses.test.mjs | 24 +++---- tests/unit/provider-default-names.test.mjs | 1 - .../unit/provider-switch-regression.test.mjs | 2 +- tests/unit/providers-validation.test.mjs | 14 ++--- web-ui/app.js | 4 +- web-ui/modules/app.methods.providers.mjs | 37 ++--------- web-ui/modules/i18n/locales/en.mjs | 2 - web-ui/modules/i18n/locales/ja.mjs | 2 - web-ui/modules/i18n/locales/vi.mjs | 2 - web-ui/modules/i18n/locales/zh-tw.mjs | 2 - web-ui/modules/i18n/locales/zh.mjs | 2 - web-ui/partials/index/modals-basic.html | 26 -------- web-ui/res/web-ui-render.precompiled.js | 62 ------------------- 15 files changed, 37 insertions(+), 170 deletions(-) diff --git a/cli/openai-bridge-retry.js b/cli/openai-bridge-retry.js index 7761f827..e312d4c8 100644 --- a/cli/openai-bridge-retry.js +++ b/cli/openai-bridge-retry.js @@ -1,16 +1,16 @@ -const DEFAULT_BRIDGE_MAX_RETRIES = 2; -const MIN_BRIDGE_MAX_RETRIES = 2; -const MAX_BRIDGE_MAX_RETRIES = 10; +const DEFAULT_BRIDGE_MAX_RETRIES = Infinity; const BASE_TRANSIENT_RETRY_DELAY_MS = 200; const MAX_TRANSIENT_RETRY_DELAY_MS = 5000; function normalizeBridgeMaxRetries(value, fallback = DEFAULT_BRIDGE_MAX_RETRIES) { const raw = Number(value); const fallbackRaw = Number(fallback); - const base = Number.isFinite(raw) ? raw : (Number.isFinite(fallbackRaw) ? fallbackRaw : DEFAULT_BRIDGE_MAX_RETRIES); - return Math.min(MAX_BRIDGE_MAX_RETRIES, Math.max(MIN_BRIDGE_MAX_RETRIES, Math.floor(base))); + if (Number.isFinite(raw) && raw >= 0) return Math.floor(raw); + if (Number.isFinite(fallbackRaw) && fallbackRaw >= 0) return Math.floor(fallbackRaw); + return DEFAULT_BRIDGE_MAX_RETRIES; } + function isTransientHttpStatus(status) { const code = Number(status); return code === 408 || code === 409 || code === 425 || code === 429 || code === 500 || code === 502 || code === 503 || code === 504 || code === 520 || code === 521 || code === 522 || code === 523 || code === 524; @@ -35,7 +35,7 @@ function getTransientRetryDelayMs(attempt) { async function retryTransientRequest(executor, options = {}) { const maxRetries = normalizeBridgeMaxRetries(options && options.maxRetries); let lastResult = null; - for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + for (let attempt = 0; attempt === 0 || attempt <= maxRetries; attempt += 1) { if (attempt > 0) { const delay = getTransientRetryDelayMs(attempt); // eslint-disable-next-line no-await-in-loop @@ -61,8 +61,6 @@ async function retryTransientRequest(executor, options = {}) { module.exports = { DEFAULT_BRIDGE_MAX_RETRIES, - MIN_BRIDGE_MAX_RETRIES, - MAX_BRIDGE_MAX_RETRIES, normalizeBridgeMaxRetries, isTransientNetworkError, isTransientHttpStatus, diff --git a/cli/openai-bridge.js b/cli/openai-bridge.js index 20892fd2..5fda67a5 100644 --- a/cli/openai-bridge.js +++ b/cli/openai-bridge.js @@ -87,7 +87,6 @@ function normalizeOpenaiUpstreamBaseUrl(rawValue) { } const { - normalizeBridgeMaxRetries, parseJsonOrError, extractChatCompletionResult, convertResponsesRequestToChatCompletions, @@ -114,11 +113,10 @@ function normalizeUpstreamEntry(entry) { const apiKey = normalizeText(entry.apiKey || entry.api_key || entry.key || ''); const headersRaw = entry.headers || entry.extraHeaders || entry.extra_headers || null; const headers = normalizeHeadersMap(headersRaw); - const maxRetries = normalizeBridgeMaxRetries(entry.maxRetries ?? entry.max_retries); if (!baseUrl || !isValidHttpUrl(baseUrl)) { return null; } - return { baseUrl, apiKey, headers, maxRetries }; + return { baseUrl, apiKey, headers }; } function normalizeHeadersMap(value) { @@ -169,7 +167,6 @@ function upsertOpenaiBridgeProvider(filePath, providerName, upstreamBaseUrl, api const baseUrl = normalizeOpenaiUpstreamBaseUrl(upstreamBaseUrl); const key = normalizeText(apiKey); const nextHeaders = normalizeHeadersMap(headers); - const maxRetries = normalizeBridgeMaxRetries(options && options.maxRetries); if (!name) { return { error: 'Provider name is required' }; @@ -191,7 +188,6 @@ function upsertOpenaiBridgeProvider(filePath, providerName, upstreamBaseUrl, api baseUrl, apiKey: key, headers: Object.keys(nextHeaders).length ? nextHeaders : existingHeaders, - maxRetries } } }; @@ -384,7 +380,7 @@ function createOpenaiBridgeHttpHandler(options = {}) { maxBytes: maxUpstreamBytes, httpAgent, httpsAgent - }), { maxRetries: upstream.maxRetries }); + })); if (!result.ok) { res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ error: `Upstream request failed: ${result.error}` })); @@ -446,7 +442,7 @@ function createOpenaiBridgeHttpHandler(options = {}) { res, model: typeof chatBody.model === 'string' ? chatBody.model : '', toolTypesByName: converted.toolTypesByName || {} - }), { maxRetries: upstream.maxRetries }); + })); if (!streamed.ok) { if (res.writableEnded || res.destroyed) { return; @@ -471,7 +467,7 @@ function createOpenaiBridgeHttpHandler(options = {}) { maxBytes: maxUpstreamBytes, httpAgent, httpsAgent - }), { maxRetries: upstream.maxRetries }); + })); if (!upstreamResult.ok) { res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` })); @@ -558,7 +554,6 @@ module.exports = { extractChatCompletionResult, buildResponsesPayloadFromChatResult, retryTransientRequest, - normalizeBridgeMaxRetries, normalizeOpenaiUpstreamBaseUrl, extractResponsesOutputText, shouldFallbackFromUpstreamResponses, diff --git a/tests/unit/openai-bridge-upstream-responses.test.mjs b/tests/unit/openai-bridge-upstream-responses.test.mjs index 872c7115..6b27c1d9 100644 --- a/tests/unit/openai-bridge-upstream-responses.test.mjs +++ b/tests/unit/openai-bridge-upstream-responses.test.mjs @@ -93,12 +93,17 @@ test('openai-bridge GET /v1 returns local bridge status without probing upstream }); -test('openai-bridge honors provider maxRetries for transient upstream failures', async () => { +test('openai-bridge keeps retrying transient upstream failures until success by default', async () => { let hitCount = 0; const upstream = http.createServer((req, res) => { if (req.url === '/v1/models' && req.method === 'GET') { hitCount += 1; - req.socket.destroy(); + if (hitCount < 5) { + req.socket.destroy(); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ object: 'list', data: [{ id: 'gpt-test' }] })); return; } res.writeHead(404, { 'Content-Type': 'application/json' }); @@ -113,8 +118,7 @@ test('openai-bridge honors provider maxRetries for transient upstream failures', providers: { test: { baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, - apiKey: 'sk-upstream', - maxRetries: 3 + apiKey: 'sk-upstream' } } }), 'utf-8'); @@ -132,15 +136,15 @@ test('openai-bridge honors provider maxRetries for transient upstream failures', headers: { Authorization: 'Bearer codexmate' } }); - assert.equal(resp.status, 502); - assert.equal(hitCount, 4); + assert.equal(resp.status, 200); + assert.equal(hitCount, 5); + assert.deepEqual(JSON.parse(resp.text), { object: 'list', data: [{ id: 'gpt-test' }] }); await bridge.close(); await upstream.close(); await rm(tmpDir, { recursive: true, force: true }); }); - test('openai-bridge retries transient upstream 524 responses', async () => { let hitCount = 0; const upstream = http.createServer((req, res) => { @@ -167,8 +171,7 @@ test('openai-bridge retries transient upstream 524 responses', async () => { providers: { test: { baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, - apiKey: 'sk-upstream', - maxRetries: 3 + apiKey: 'sk-upstream' } } }), 'utf-8'); @@ -223,8 +226,7 @@ test('openai-bridge retries transient upstream 524 responses for streaming Respo providers: { test: { baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, - apiKey: 'sk-upstream', - maxRetries: 3 + apiKey: 'sk-upstream' } } }), 'utf-8'); diff --git a/tests/unit/provider-default-names.test.mjs b/tests/unit/provider-default-names.test.mjs index d7c8c88f..8367dde9 100644 --- a/tests/unit/provider-default-names.test.mjs +++ b/tests/unit/provider-default-names.test.mjs @@ -39,7 +39,6 @@ test('Codex add-provider modal defaults to an auto-increment numeric provider na key: '', model: '', useTransform: false, - openaiBridgeMaxRetries: 2 }); context.providersList.push({ name: '3' }); diff --git a/tests/unit/provider-switch-regression.test.mjs b/tests/unit/provider-switch-regression.test.mjs index 86577d3b..19609c82 100644 --- a/tests/unit/provider-switch-regression.test.mjs +++ b/tests/unit/provider-switch-regression.test.mjs @@ -270,7 +270,7 @@ test('updateProvider keeps existing key when edit key input is blank', async () } }]); assert.strictEqual(context.showEditModal, false); - assert.deepStrictEqual(context.editingProvider, { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false, openaiBridgeMaxRetries: 2 }); + assert.deepStrictEqual(context.editingProvider, { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false }); // c3c9ee5:不再 loadAll,断言本地 providersList url 已更新。 assert.strictEqual(context.loadAllCalls, 0); assert.strictEqual(context.providersList[0].url, 'https://api.example.com/v1'); diff --git a/tests/unit/providers-validation.test.mjs b/tests/unit/providers-validation.test.mjs index 0829dd77..9c6cdc8e 100644 --- a/tests/unit/providers-validation.test.mjs +++ b/tests/unit/providers-validation.test.mjs @@ -22,8 +22,8 @@ function createContext(overrides = {}, apiImpl = async () => ({ success: true }) showAddModal: true, showEditModal: false, resetConfigLoading: false, - newProvider: { name: '', url: '', key: '', model: '', useTransform: false, openaiBridgeMaxRetries: 2 }, - editingProvider: { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false, openaiBridgeMaxRetries: 2 }, + newProvider: { name: '', url: '', key: '', model: '', useTransform: false }, + editingProvider: { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false }, claudeConfigs: {}, showMessage(text, type) { messages.push({ text: String(text), type: type || 'info' }); @@ -94,7 +94,7 @@ test('addProvider normalizes trimmed values and submits sanitized payload', asyn } }]); assert.strictEqual(context.showAddModal, false); - assert.deepStrictEqual(context.newProvider, { name: '1', url: '', key: '', model: '', useTransform: false, openaiBridgeMaxRetries: 2 }); + assert.deepStrictEqual(context.newProvider, { name: '1', url: '', key: '', model: '', useTransform: false }); // c3c9ee5:增删改不再触发 loadAll,改为本地 providersList 增量更新。 assert.deepStrictEqual(loadAllCalls, []); assert.ok( @@ -181,7 +181,7 @@ test('provider validation rejects reserved proxy name on add', () => { assert.strictEqual(context.canSubmitProvider('add'), false); }); -test('transform provider submits configurable retry count with minimum two', async () => { +test('transform provider does not expose retry count in submitted payload', async () => { const apiCalls = []; const { context } = createContext({ newProvider: { @@ -189,8 +189,7 @@ test('transform provider submits configurable retry count with minimum two', asy url: 'https://api.example.com/v1', key: 'sk-live', model: 'gpt-e2e', - useTransform: true, - openaiBridgeMaxRetries: 1 + useTransform: true } }, async (action, params) => { apiCalls.push({ action, params }); @@ -206,8 +205,7 @@ test('transform provider submits configurable retry count with minimum two', asy url: 'https://api.example.com/v1', key: 'sk-live', model: 'gpt-e2e', - useTransform: true, - openaiBridgeMaxRetries: 2 + useTransform: true } }]); }); diff --git a/web-ui/app.js b/web-ui/app.js index 48b8c3fd..7b5e6619 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -290,9 +290,9 @@ document.addEventListener('DOMContentLoaded', () => { appVersionStatusChecked: false, appVersionStatusCheckedAt: '', appVersionStatusSource: '', - newProvider: { name: '', url: '', key: '', model: '', useTransform: false, openaiBridgeMaxRetries: 2 }, + newProvider: { name: '', url: '', key: '', model: '', useTransform: false }, resetConfigLoading: false, - editingProvider: { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false, openaiBridgeMaxRetries: 2 }, + editingProvider: { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false }, newModelName: '', currentClaudeConfig: '', currentClaudeModel: '', diff --git a/web-ui/modules/app.methods.providers.mjs b/web-ui/modules/app.methods.providers.mjs index 445d5a89..9e914a82 100644 --- a/web-ui/modules/app.methods.providers.mjs +++ b/web-ui/modules/app.methods.providers.mjs @@ -41,13 +41,6 @@ function findProviderByName(list, name) { return (Array.isArray(list) ? list : []).find((item) => item && normalizeText(item.name) === target) || null; } -function normalizeBridgeMaxRetries(value, fallback = 2) { - const raw = Number(value); - const fallbackRaw = Number(fallback); - const base = Number.isFinite(raw) ? raw : (Number.isFinite(fallbackRaw) ? fallbackRaw : 2); - return Math.min(10, Math.max(2, Math.floor(base))); -} - function normalizeProviderDraftState(target) { if (!target || typeof target !== 'object') return; if (typeof target.name === 'string') { @@ -62,9 +55,6 @@ function normalizeProviderDraftState(target) { if (typeof target.key === 'string') { target.key = target.key.trim(); } - if (target.useTransform || target.openaiBridgeMaxRetries !== undefined) { - target.openaiBridgeMaxRetries = normalizeBridgeMaxRetries(target.openaiBridgeMaxRetries); - } } function maskKeyLocal(key) { @@ -87,13 +77,11 @@ function getProviderValidationForContext(vm, mode = 'add') { const model = normalizeText(draft && draft.model); const key = normalizeText(draft && draft.key); const useTransform = !!(draft && draft.useTransform); - const openaiBridgeMaxRetries = normalizeBridgeMaxRetries(draft && draft.openaiBridgeMaxRetries); const errors = { name: '', url: '', key: '', model: '', - openaiBridgeMaxRetries: '' }; if (mode === 'add') { @@ -124,10 +112,6 @@ function getProviderValidationForContext(vm, mode = 'add') { errors.model = '模型名称必填'; } - if (useTransform && openaiBridgeMaxRetries < 2) { - errors.openaiBridgeMaxRetries = '重试次数最小为 2'; - } - return { mode, name, @@ -135,9 +119,8 @@ function getProviderValidationForContext(vm, mode = 'add') { key, model, useTransform, - openaiBridgeMaxRetries, errors, - ok: !errors.name && !errors.url && !errors.key && !errors.model && !errors.openaiBridgeMaxRetries + ok: !errors.name && !errors.url && !errors.key && !errors.model }; } @@ -191,7 +174,7 @@ export function createProvidersMethods(options = {}) { normalizeProviderDraftState(this.newProvider); const validation = getProviderValidationForContext(this, 'add'); if (!validation.ok) { - return this.showMessage(validation.errors.name || validation.errors.url || validation.errors.key || validation.errors.model || validation.errors.openaiBridgeMaxRetries || this.t('toast.provider.fieldsRequired'), 'error'); + return this.showMessage(validation.errors.name || validation.errors.url || validation.errors.key || validation.errors.model || this.t('toast.provider.fieldsRequired'), 'error'); } try { @@ -203,7 +186,6 @@ export function createProvidersMethods(options = {}) { }; if (this.newProvider && this.newProvider.useTransform) { payload.useTransform = true; - payload.openaiBridgeMaxRetries = validation.openaiBridgeMaxRetries; } const suggestedModel = validation.model; const res = await api('add-provider', payload); @@ -218,7 +200,6 @@ export function createProvidersMethods(options = {}) { url: validation.url, upstreamUrl: '', codexmate_bridge: payload.useTransform ? 'openai' : '', - openaiBridgeMaxRetries: payload.useTransform ? validation.openaiBridgeMaxRetries : undefined, key: maskKeyLocal(payload.key), hasKey: !!payload.key, models: suggestedModel ? [{ id: suggestedModel, name: suggestedModel, cost: null, contextWindow: undefined, maxTokens: undefined }] : [], @@ -345,7 +326,6 @@ export function createProvidersMethods(options = {}) { key: '', model: '', useTransform: isTransform, - openaiBridgeMaxRetries: normalizeBridgeMaxRetries(provider.openaiBridgeMaxRetries) }; this.showAddProviderKey = false; this.showAddModal = true; @@ -358,7 +338,6 @@ export function createProvidersMethods(options = {}) { key: '', model: '', useTransform: false, - openaiBridgeMaxRetries: 2 }; this.showAddProviderKey = false; this.showAddModal = true; @@ -387,7 +366,6 @@ export function createProvidersMethods(options = {}) { ? provider.nonEditable : this.isNonDeletableProvider(provider), useTransform: isTransformProvider, - openaiBridgeMaxRetries: normalizeBridgeMaxRetries(provider.openaiBridgeMaxRetries) }; this._editProviderOriginalKey = ''; this._editProviderRealKeyLoaded = false; @@ -428,9 +406,6 @@ export function createProvidersMethods(options = {}) { && res.baseUrl.trim() ) { this.editingProvider.url = normalizeProviderUrl(res.baseUrl); - if (res.maxRetries !== undefined) { - this.editingProvider.openaiBridgeMaxRetries = normalizeBridgeMaxRetries(res.maxRetries); - } } } catch (_) { // ignore @@ -447,13 +422,12 @@ export function createProvidersMethods(options = {}) { normalizeProviderDraftState(this.editingProvider); const validation = getProviderValidationForContext(this, 'edit'); if (!validation.ok) { - return this.showMessage(validation.errors.name || validation.errors.url || validation.errors.openaiBridgeMaxRetries || this.t('toast.provider.urlRequired'), 'error'); + return this.showMessage(validation.errors.name || validation.errors.url || this.t('toast.provider.urlRequired'), 'error'); } const params = { name: validation.name, url: validation.url }; if (this.editingProvider && this.editingProvider.useTransform) { params.useTransform = true; - params.openaiBridgeMaxRetries = validation.openaiBridgeMaxRetries; } if (this._editProviderRealKeyLoaded) { const currentKey = typeof this.editingProvider.key === 'string' ? this.editingProvider.key : ''; @@ -479,7 +453,6 @@ export function createProvidersMethods(options = {}) { key: keyUpdated ? maskKeyLocal(params.key) : p.key, hasKey: keyUpdated ? !!params.key : p.hasKey, codexmate_bridge: params.useTransform ? 'openai' : p.codexmate_bridge, - openaiBridgeMaxRetries: params.useTransform ? validation.openaiBridgeMaxRetries : p.openaiBridgeMaxRetries }; } return p; @@ -497,7 +470,7 @@ export function createProvidersMethods(options = {}) { this.showEditProviderKey = false; this._editProviderOriginalKey = ''; this._editProviderRealKeyLoaded = false; - this.editingProvider = { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false, openaiBridgeMaxRetries: 2 }; + this.editingProvider = { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false }; }, toggleEditProviderKey() { @@ -580,7 +553,7 @@ export function createProvidersMethods(options = {}) { closeAddModal() { this.showAddModal = false; this.showAddProviderKey = false; - this.newProvider = { name: nextCodexProviderName(this.providersList), url: '', key: '', model: '', useTransform: false, openaiBridgeMaxRetries: 2 }; + this.newProvider = { name: nextCodexProviderName(this.providersList), url: '', key: '', model: '', useTransform: false }; }, toggleAddProviderKey() { diff --git a/web-ui/modules/i18n/locales/en.mjs b/web-ui/modules/i18n/locales/en.mjs index 82d1ed18..b8f98010 100644 --- a/web-ui/modules/i18n/locales/en.mjs +++ b/web-ui/modules/i18n/locales/en.mjs @@ -517,8 +517,6 @@ const en = Object.freeze({ 'modal.claudeConfigEdit.title': 'Edit Claude Code config', 'field.useBuiltinTransform': 'Use built-in transform (OpenAI compatible)', 'hint.useBuiltinTransform': 'When enabled, base_url points to codexmate built-in transform service; Codex token is fixed to codexmate.', - 'field.transformMaxRetries': 'Built-in transform retries', - 'hint.transformMaxRetries': 'Default 2, minimum 2; number of retries after the first failed attempt.', // Config template / agents modals 'modal.configTemplate.label': 'config.toml template', diff --git a/web-ui/modules/i18n/locales/ja.mjs b/web-ui/modules/i18n/locales/ja.mjs index d005bf39..9ba0be77 100644 --- a/web-ui/modules/i18n/locales/ja.mjs +++ b/web-ui/modules/i18n/locales/ja.mjs @@ -519,8 +519,6 @@ const ja = Object.freeze({ 'modal.claudeConfigEdit.title': 'Claude Code 設定編集', 'field.useBuiltinTransform': '内蔵変換を使用(OpenAI 形式互換)', 'hint.useBuiltinTransform': '有効時:書き込まれる base_url は codexmate 内蔵変換サービスを指します。Codex のトークンは codexmate に固定されます。', - 'field.transformMaxRetries': '内蔵変換の再試行回数', - 'hint.transformMaxRetries': '既定値は 2、最小値も 2。初回失敗後に再試行する最大回数です。', // Config template / agents modals 'modal.configTemplate.label': 'config.toml テンプレート', diff --git a/web-ui/modules/i18n/locales/vi.mjs b/web-ui/modules/i18n/locales/vi.mjs index 69919c48..4a616e4d 100644 --- a/web-ui/modules/i18n/locales/vi.mjs +++ b/web-ui/modules/i18n/locales/vi.mjs @@ -737,8 +737,6 @@ const vi = Object.freeze({ 'modal.claudeConfigEdit.title': 'Chỉnh sửa cấu hình Claude Code', 'field.useBuiltinTransform': 'Dùng transform tích hợp (tương thích OpenAI)', 'hint.useBuiltinTransform': 'Khi bật, base_url trỏ đến dịch vụ transform tích hợp của codexmate.', - 'field.transformMaxRetries': 'Số lần thử lại transform tích hợp', - 'hint.transformMaxRetries': 'Mặc định 2, tối thiểu 2; số lần thử lại sau lần gọi đầu thất bại.', 'modal.configTemplate.title': 'Trình soạn thảo template cấu hình (xác nhận thủ công)', 'modal.configTemplate.label': 'Template config.toml', 'modal.configTemplate.placeholder': 'Chỉnh sửa template config.toml tại đây', diff --git a/web-ui/modules/i18n/locales/zh-tw.mjs b/web-ui/modules/i18n/locales/zh-tw.mjs index bc7c2a1a..b3fcfaf9 100644 --- a/web-ui/modules/i18n/locales/zh-tw.mjs +++ b/web-ui/modules/i18n/locales/zh-tw.mjs @@ -517,8 +517,6 @@ const zhTw = Object.freeze({ 'modal.claudeConfigEdit.title': '編輯 Claude Code 設定', 'field.useBuiltinTransform': '使用內建轉換(兼容 OpenAI 格式)', 'hint.useBuiltinTransform': '開啟後:寫入的 base_url 會指向 codexmate 內建轉換服務;Codex 使用的令牌固定為 codexmate。', - 'field.transformMaxRetries': '內建轉換重試次數', - 'hint.transformMaxRetries': '預設 2,最小 2;表示失敗後最多重試的次數。', // Config template / agents modals 'modal.configTemplate.label': 'config.toml 模板', diff --git a/web-ui/modules/i18n/locales/zh.mjs b/web-ui/modules/i18n/locales/zh.mjs index e68368d9..3cb69619 100644 --- a/web-ui/modules/i18n/locales/zh.mjs +++ b/web-ui/modules/i18n/locales/zh.mjs @@ -517,8 +517,6 @@ const zh = Object.freeze({ 'modal.claudeConfigEdit.title': '编辑 Claude Code 配置', 'field.useBuiltinTransform': '使用内建转换(兼容 OpenAI 格式)', 'hint.useBuiltinTransform': '开启后:写入的 base_url 会指向 codexmate 内建转换服务;Codex 使用的令牌固定为 codexmate。', - 'field.transformMaxRetries': '内建转换重试次数', - 'hint.transformMaxRetries': '默认 2,最小 2;表示失败后最多重试的次数。', // Config template / agents modals 'modal.configTemplate.label': 'config.toml 模板', diff --git a/web-ui/partials/index/modals-basic.html b/web-ui/partials/index/modals-basic.html index 92c682f2..d4e4fcdb 100644 --- a/web-ui/partials/index/modals-basic.html +++ b/web-ui/partials/index/modals-basic.html @@ -53,19 +53,6 @@ {{ t('field.useBuiltinTransform') }} -
- - -
{{ t('hint.transformMaxRetries') }}
-
{{ providerFieldError('add', 'openaiBridgeMaxRetries') }}
-
@@ -104,19 +91,6 @@
-
- - -
{{ t('hint.transformMaxRetries') }}
-
{{ providerFieldError('edit', 'openaiBridgeMaxRetries') }}
-
diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js index 7e2e5d95..e8c163d6 100644 --- a/web-ui/res/web-ui-render.precompiled.js +++ b/web-ui/res/web-ui-render.precompiled.js @@ -7171,37 +7171,6 @@ return function render(_ctx, _cache) { _createTextVNode(" " + _toDisplayString(_ctx.t('field.useBuiltinTransform')), 1 /* TEXT */) ]) ]), - (_ctx.newProvider.useTransform) - ? (_openBlock(), _createElementBlock("div", { - key: 0, - class: "form-group" - }, [ - _createElementVNode("label", { class: "form-label" }, _toDisplayString(_ctx.t('field.transformMaxRetries')), 1 /* TEXT */), - _withDirectives(_createElementVNode("input", { - "onUpdate:modelValue": $event => ((_ctx.newProvider.openaiBridgeMaxRetries) = $event), - type: "number", - min: "2", - max: "10", - step: "1", - class: _normalizeClass(['form-input', { invalid: !!_ctx.providerFieldError('add', 'openaiBridgeMaxRetries') }]), - onBlur: $event => (_ctx.normalizeProviderDraft('add')) - }, null, 42 /* CLASS, PROPS, NEED_HYDRATION */, ["onUpdate:modelValue", "onBlur"]), [ - [ - _vModelText, - _ctx.newProvider.openaiBridgeMaxRetries, - void 0, - { number: true } - ] - ]), - _createElementVNode("div", { class: "form-hint" }, _toDisplayString(_ctx.t('hint.transformMaxRetries')), 1 /* TEXT */), - (_ctx.providerFieldError('add', 'openaiBridgeMaxRetries')) - ? (_openBlock(), _createElementBlock("div", { - key: 0, - class: "form-hint form-error" - }, _toDisplayString(_ctx.providerFieldError('add', 'openaiBridgeMaxRetries')), 1 /* TEXT */)) - : _createCommentVNode("v-if", true) - ])) - : _createCommentVNode("v-if", true), _createElementVNode("div", { class: "btn-group" }, [ _createElementVNode("button", { class: "btn btn-cancel", @@ -7314,37 +7283,6 @@ return function render(_ctx, _cache) { ], 8 /* PROPS */, ["onClick", "title", "aria-label"]) ]) ]), - (_ctx.editingProvider.useTransform) - ? (_openBlock(), _createElementBlock("div", { - key: 0, - class: "form-group" - }, [ - _createElementVNode("label", { class: "form-label" }, _toDisplayString(_ctx.t('field.transformMaxRetries')), 1 /* TEXT */), - _withDirectives(_createElementVNode("input", { - "onUpdate:modelValue": $event => ((_ctx.editingProvider.openaiBridgeMaxRetries) = $event), - type: "number", - min: "2", - max: "10", - step: "1", - class: _normalizeClass(['form-input', { invalid: !!_ctx.providerFieldError('edit', 'openaiBridgeMaxRetries') }]), - onBlur: $event => (_ctx.normalizeProviderDraft('edit')) - }, null, 42 /* CLASS, PROPS, NEED_HYDRATION */, ["onUpdate:modelValue", "onBlur"]), [ - [ - _vModelText, - _ctx.editingProvider.openaiBridgeMaxRetries, - void 0, - { number: true } - ] - ]), - _createElementVNode("div", { class: "form-hint" }, _toDisplayString(_ctx.t('hint.transformMaxRetries')), 1 /* TEXT */), - (_ctx.providerFieldError('edit', 'openaiBridgeMaxRetries')) - ? (_openBlock(), _createElementBlock("div", { - key: 0, - class: "form-hint form-error" - }, _toDisplayString(_ctx.providerFieldError('edit', 'openaiBridgeMaxRetries')), 1 /* TEXT */)) - : _createCommentVNode("v-if", true) - ])) - : _createCommentVNode("v-if", true), _createElementVNode("div", { class: "btn-group" }, [ _createElementVNode("button", { class: "btn btn-cancel", From 5c9c66d07eeaee029c6181b90f5ab21613f02037 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sun, 12 Jul 2026 15:48:14 +0000 Subject: [PATCH 07/13] fix(openai-bridge): align transient retries with maxx --- cli/openai-bridge-retry.js | 3 +- cli/openai-bridge-runtime.js | 15 ++++- .../openai-bridge-upstream-responses.test.mjs | 66 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/cli/openai-bridge-retry.js b/cli/openai-bridge-retry.js index e312d4c8..b7060116 100644 --- a/cli/openai-bridge-retry.js +++ b/cli/openai-bridge-retry.js @@ -13,7 +13,8 @@ function normalizeBridgeMaxRetries(value, fallback = DEFAULT_BRIDGE_MAX_RETRIES) function isTransientHttpStatus(status) { const code = Number(status); - return code === 408 || code === 409 || code === 425 || code === 429 || code === 500 || code === 502 || code === 503 || code === 504 || code === 520 || code === 521 || code === 522 || code === 523 || code === 524; + if (code === 408 || code === 409 || code === 425 || code === 429) return true; + return code >= 500 && code <= 599; } function isTransientNetworkError(error) { diff --git a/cli/openai-bridge-runtime.js b/cli/openai-bridge-runtime.js index 352a2dbc..084c70e9 100644 --- a/cli/openai-bridge-runtime.js +++ b/cli/openai-bridge-runtime.js @@ -1472,7 +1472,20 @@ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) { response: { id: state.responseId, model: state.model, - created_at: state.createdAt + created_at: state.createdAt, + status: 'in_progress', + output: [] + } + }); + writeSse(res, 'response.in_progress', { + type: 'response.in_progress', + sequence_number: state.nextSeq(), + response: { + id: state.responseId, + model: state.model, + created_at: state.createdAt, + status: 'in_progress', + output: [] } }); diff --git a/tests/unit/openai-bridge-upstream-responses.test.mjs b/tests/unit/openai-bridge-upstream-responses.test.mjs index 6b27c1d9..8402162b 100644 --- a/tests/unit/openai-bridge-upstream-responses.test.mjs +++ b/tests/unit/openai-bridge-upstream-responses.test.mjs @@ -145,6 +145,17 @@ test('openai-bridge keeps retrying transient upstream failures until success by await rm(tmpDir, { recursive: true, force: true }); }); + +test('isTransientHttpStatus retries all 5xx statuses like maxx provider classification', async () => { + const { isTransientHttpStatus } = require('../../cli/openai-bridge-retry'); + for (const status of [500, 501, 502, 503, 504, 520, 524, 599]) { + assert.equal(isTransientHttpStatus(status), true, `status ${status} should be transient`); + } + for (const status of [400, 401, 403, 404, 422]) { + assert.equal(isTransientHttpStatus(status), false, `status ${status} should not be transient`); + } +}); + test('openai-bridge retries transient upstream 524 responses', async () => { let hitCount = 0; const upstream = http.createServer((req, res) => { @@ -1875,3 +1886,58 @@ test('openai-bridge destroys response when post-header end fails before completi await upstream.close(); await rm(tmpDir, { recursive: true, force: true }); }); + +test('streaming Responses conversion emits in-progress lifecycle before completion', async () => { + const upstream = http.createServer((req, res) => { + if (req.url === '/v1/chat/completions' && req.method === 'POST') { + res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8' }); + res.write('data: {"choices":[{"delta":{"content":"hi"}}]}\n\n'); + res.end('data: [DONE]\n\n'); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); + const { port: upstreamPort } = await listen(upstream); + + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'codexmate-bridge-test-')); + const settingsFile = path.join(tmpDir, 'bridge.json'); + await writeFile(settingsFile, JSON.stringify({ + version: 1, + providers: { + test: { + baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, + apiKey: '***' + } + } + }), 'utf-8'); + + const handler = createOpenaiBridgeHttpHandler({ settingsFile, expectedToken: 'codexmate' }); + const bridge = http.createServer((req, res) => { + if (!handler(req, res)) { + res.statusCode = 404; + res.end('not handled'); + } + }); + const { port: bridgePort } = await listen(bridge); + + const resp = await requestText(`http://127.0.0.1:${bridgePort}/bridge/openai/test/v1/responses`, { + method: 'POST', + headers: { + Authorization: '***', + Accept: 'text/event-stream', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ model: 'gpt-test', input: 'hello', stream: true }) + }); + + assert.equal(resp.status, 200); + assert.match(resp.text, /event: response\.created/); + assert.match(resp.text, /event: response\.in_progress/); + assert.match(resp.text, /event: response\.completed/); + assert.match(resp.text, /data: \[DONE\]/); + + await bridge.close(); + await upstream.close(); + await rm(tmpDir, { recursive: true, force: true }); +}); From f1429fac0cfc77be49c0a6db2e47ce8dd43a6316 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 13 Jul 2026 08:22:50 +0000 Subject: [PATCH 08/13] feat(openclaw): enrich config tab diagnostics --- tests/unit/openclaw-core.test.mjs | 49 +++++++ tests/unit/web-ui-behavior-parity.test.mjs | 6 +- web-ui/modules/app.methods.openclaw-core.mjs | 87 ++++++++++++ .../partials/index/panel-config-openclaw.html | 38 +++++ web-ui/res/web-ui-render.precompiled.js | 55 ++++++- web-ui/styles/openclaw-structured.css | 134 ++++++++++++++++++ 6 files changed, 367 insertions(+), 2 deletions(-) diff --git a/tests/unit/openclaw-core.test.mjs b/tests/unit/openclaw-core.test.mjs index 94a9e384..8d647a56 100644 --- a/tests/unit/openclaw-core.test.mjs +++ b/tests/unit/openclaw-core.test.mjs @@ -450,3 +450,52 @@ test('formatProviderValue shows readable labels for env template and SecretRef i 'SecretRef(env:default:OPENAI_API_KEY)' ); }); + +test('getOpenclawConfigSummary extracts high-signal config fields', () => { + const context = { + ...methods + }; + const summary = methods.getOpenclawConfigSummary.call(context, { + content: JSON.stringify({ + agents: { + defaults: { + model: { + primary: 'openai/gpt-5.4', + fallbacks: ['anthropic/claude-sonnet'] + }, + workspace: '/repo/workspace' + } + }, + browser: { + enabled: true, + headless: true, + executablePath: '/usr/bin/chrome' + }, + models: { + providers: { + openai: {}, + anthropic: {} + } + } + }) + }); + + assert.deepStrictEqual(summary.slice(0, 6), [ + { key: 'primary', label: '默认模型', value: 'openai/gpt-5.4' }, + { key: 'fallbacks', label: 'Fallback', value: 'anthropic/claude-sonnet' }, + { key: 'workspace', label: 'Workspace', value: '/repo/workspace' }, + { key: 'browser', label: 'Browser', value: 'Headless' }, + { key: 'browser-path', label: 'Chrome', value: '/usr/bin/chrome' }, + { key: 'providers', label: 'Providers', value: 'openai / anthropic' } + ]); +}); + +test('getOpenclawConfigSummary reports parse errors without throwing', () => { + const context = { + ...methods + }; + assert.deepStrictEqual( + methods.getOpenclawConfigSummary.call(context, { content: '{ broken' }), + [{ key: 'parse-error', label: '配置状态', value: '解析失败', tone: 'warning' }] + ); +}); diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index 2acb042e..48beb534 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -832,7 +832,11 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'getCurrentPromptPresetDefaultName', 'saveEditorPromptAsPreset', 'previewTaskPlanFromChat', - 'planAndRunTaskOrchestrationFromChat' + 'planAndRunTaskOrchestrationFromChat', + 'getOpenclawConfigSummary', + 'getOpenclawQuickWorkspaceFiles', + 'getOpenclawStatusSummaryItems', + 'openOpenclawQuickWorkspaceFile' ); const allowedMissingCurrentMethodKeys = [ 'convertSession', diff --git a/web-ui/modules/app.methods.openclaw-core.mjs b/web-ui/modules/app.methods.openclaw-core.mjs index 62e4f1d0..19ced347 100644 --- a/web-ui/modules/app.methods.openclaw-core.mjs +++ b/web-ui/modules/app.methods.openclaw-core.mjs @@ -224,6 +224,31 @@ function readPreferredProviderModels(records) { return []; } +function readNestedValue(record, path) { + if (!isPlainRecord(record)) return undefined; + let cursor = record; + for (const key of path) { + if (!isPlainRecord(cursor) || !Object.prototype.hasOwnProperty.call(cursor, key)) { + return undefined; + } + cursor = cursor[key]; + } + return cursor; +} + +function formatOpenclawSummaryValue(value, fallback = '未配置') { + if (Array.isArray(value)) { + const list = value + .map(item => (typeof item === 'string' ? item.trim() : String(item || '').trim())) + .filter(Boolean); + return list.length ? list.join(' / ') : fallback; + } + if (value === true) return '启用'; + if (value === false) return '关闭'; + const text = typeof value === 'string' ? value.trim() : String(value ?? '').trim(); + return text || fallback; +} + export function createOpenclawCoreMethods() { return { getOpenclawParser() { @@ -472,6 +497,68 @@ export function createOpenclawCoreMethods() { }; }, + getOpenclawConfigSummary(config) { + const content = config && typeof config.content === 'string' ? config.content : ''; + if (!content.trim()) { + return []; + } + const parsed = this.parseOpenclawContent(content, { allowEmpty: true }); + if (!parsed.ok || !isPlainRecord(parsed.data)) { + return [ + { key: 'parse-error', label: '配置状态', value: '解析失败', tone: 'warning' } + ]; + } + const data = parsed.data; + const agentDefaults = readNestedValue(data, ['agents', 'defaults']); + const modelConfig = isPlainRecord(agentDefaults) ? agentDefaults.model : undefined; + const primaryModel = isPlainRecord(modelConfig) + ? modelConfig.primary + : (typeof modelConfig === 'string' ? modelConfig : readNestedValue(data, ['agent', 'model'])); + const fallbacks = isPlainRecord(modelConfig) && Array.isArray(modelConfig.fallbacks) + ? modelConfig.fallbacks + : []; + const workspace = isPlainRecord(agentDefaults) ? agentDefaults.workspace : ''; + const browser = isPlainRecord(data.browser) ? data.browser : {}; + const browserMode = browser.enabled === false + ? '关闭' + : (browser.headless === true ? 'Headless' : (browser.headless === false ? '可见窗口' : '未声明')); + const executablePath = typeof browser.executablePath === 'string' ? browser.executablePath.trim() : ''; + const providerNames = collectDistinctProviderKeys( + readNestedValue(data, ['models', 'providers']), + data.providers + ); + return [ + { key: 'primary', label: '默认模型', value: formatOpenclawSummaryValue(primaryModel) }, + { key: 'fallbacks', label: 'Fallback', value: formatOpenclawSummaryValue(fallbacks) }, + { key: 'workspace', label: 'Workspace', value: formatOpenclawSummaryValue(workspace) }, + { key: 'browser', label: 'Browser', value: browserMode }, + { key: 'browser-path', label: 'Chrome', value: formatOpenclawSummaryValue(executablePath) }, + { key: 'providers', label: 'Providers', value: providerNames.length ? providerNames.join(' / ') : '未配置' } + ]; + }, + + getOpenclawStatusSummaryItems() { + const current = this.openclawConfigs && this.currentOpenclawConfig + ? this.openclawConfigs[this.currentOpenclawConfig] + : null; + const configPath = this.openclawConfigPath || '~/.openclaw/openclaw.json'; + return [ + { key: 'config-path', label: 'Config', value: configPath, tone: this.openclawConfigExists ? 'ok' : 'warning' }, + ...this.getOpenclawConfigSummary(current || {}).slice(0, 4) + ]; + }, + + getOpenclawQuickWorkspaceFiles() { + return ['AGENTS.md', 'SOUL.md', 'USER.md', 'TOOLS.md', 'HEARTBEAT.md']; + }, + + openOpenclawQuickWorkspaceFile(fileName) { + const normalized = typeof fileName === 'string' ? fileName.trim() : ''; + if (!normalized) return; + this.openclawWorkspaceFileName = normalized; + this.openOpenclawWorkspaceEditor(); + }, + syncOpenclawQuickFromText(options = {}) { const silent = !!options.silent; const parsed = this.parseOpenclawContent(this.openclawEditing.content, { allowEmpty: true }); diff --git a/web-ui/partials/index/panel-config-openclaw.html b/web-ui/partials/index/panel-config-openclaw.html index a48ac9b4..34d1481a 100644 --- a/web-ui/partials/index/panel-config-openclaw.html +++ b/web-ui/partials/index/panel-config-openclaw.html @@ -11,8 +11,41 @@
+
+
+
+
+
OPENCLAW
+
配置与本地诊断
+
+
只读摘要,不读取 runtime 聊天消息
+
+
+
+
{{ item.label }}
+
{{ item.value }}
+
+
+
+ Browser 状态来自配置文件摘要;Headless 表示它不是用户可见浏览器窗口。此页不展示群聊/私聊运行会话。 +
+
+
+
+
工作区快捷文件
+
+ +
diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js index e8c163d6..ddb18424 100644 --- a/web-ui/res/web-ui-render.precompiled.js +++ b/web-ui/res/web-ui-render.precompiled.js @@ -2243,8 +2243,49 @@ return function render(_ctx, _cache) { ])) : _createCommentVNode("v-if", true), _createElementVNode("div", { class: "openclaw-layout" }, [ + _createElementVNode("section", { + class: "settings-card settings-card--wide openclaw-summary-card", + "aria-label": "OpenClaw 当前摘要" + }, [ + _createElementVNode("div", { class: "settings-card-body" }, [ + _createElementVNode("div", { class: "openclaw-summary-header" }, [ + _createElementVNode("div", null, [ + _createElementVNode("div", { class: "summary-eyebrow" }, "OPENCLAW"), + _createElementVNode("div", { class: "summary-title" }, "配置与本地诊断") + ]), + _createElementVNode("div", { class: "summary-note" }, "只读摘要,不读取 runtime 聊天消息") + ]), + _createElementVNode("div", { class: "openclaw-summary-grid" }, [ + (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.getOpenclawStatusSummaryItems(), (item) => { + return (_openBlock(), _createElementBlock("div", { + key: item.key, + class: _normalizeClass(['openclaw-summary-item', item.tone || '']) + }, [ + _createElementVNode("div", { class: "summary-item-label" }, _toDisplayString(item.label), 1 /* TEXT */), + _createElementVNode("div", { class: "summary-item-value" }, _toDisplayString(item.value), 1 /* TEXT */) + ], 2 /* CLASS */)) + }), 128 /* KEYED_FRAGMENT */)) + ]), + _createElementVNode("div", { class: "openclaw-diagnostic-note" }, " Browser 状态来自配置文件摘要;Headless 表示它不是用户可见浏览器窗口。此页不展示群聊/私聊运行会话。 ") + ]) + ]), _createElementVNode("section", { class: "settings-card settings-card--wide openclaw-workspace-card" }, [ _createElementVNode("div", { class: "settings-card-body" }, [ + _createElementVNode("div", { class: "openclaw-section-title" }, "工作区快捷文件"), + _createElementVNode("div", { + class: "openclaw-quick-files", + "aria-label": "OpenClaw workspace quick files" + }, [ + (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.getOpenclawQuickWorkspaceFiles(), (fileName) => { + return (_openBlock(), _createElementBlock("button", { + key: fileName, + type: "button", + class: "openclaw-quick-file-btn", + onClick: $event => (_ctx.openOpenclawQuickWorkspaceFile(fileName)), + disabled: _ctx.loading || !!_ctx.initError || _ctx.agentsLoading + }, _toDisplayString(fileName), 9 /* TEXT, PROPS */, ["onClick", "disabled"])) + }), 128 /* KEYED_FRAGMENT */)) + ]), _createElementVNode("div", { class: "openclaw-tools-grid" }, [ _createElementVNode("button", { class: "openclaw-tool-btn", @@ -2315,7 +2356,19 @@ return function render(_ctx, _cache) { _createElementVNode("div", { class: "card-icon" }, _toDisplayString(name.charAt(0).toUpperCase()), 1 /* TEXT */), _createElementVNode("div", { class: "card-content" }, [ _createElementVNode("div", { class: "card-title" }, _toDisplayString(name), 1 /* TEXT */), - _createElementVNode("div", { class: "card-subtitle" }, _toDisplayString(_ctx.openclawSubtitle(config)), 1 /* TEXT */) + _createElementVNode("div", { class: "card-subtitle" }, _toDisplayString(_ctx.openclawSubtitle(config)), 1 /* TEXT */), + (_ctx.openclawHasContent(config)) + ? (_openBlock(), _createElementBlock("div", { + key: 0, + class: "openclaw-config-summary-mini" + }, [ + (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.getOpenclawConfigSummary(config).slice(0, 3), (item) => { + return (_openBlock(), _createElementBlock("span", { + key: item.key + }, _toDisplayString(item.label) + ": " + _toDisplayString(item.value), 1 /* TEXT */)) + }), 128 /* KEYED_FRAGMENT */)) + ])) + : _createCommentVNode("v-if", true) ]) ]), _createElementVNode("div", { class: "card-trailing" }, [ diff --git a/web-ui/styles/openclaw-structured.css b/web-ui/styles/openclaw-structured.css index 2db1f9aa..2c1491e0 100644 --- a/web-ui/styles/openclaw-structured.css +++ b/web-ui/styles/openclaw-structured.css @@ -363,6 +363,7 @@ } .openclaw-workspace-card.settings-card, +.openclaw-summary-card, .openclaw-configs-card { margin: 0; padding: 0; @@ -375,17 +376,124 @@ } .openclaw-workspace-card.settings-card:hover, +.openclaw-summary-card:hover, .openclaw-configs-card:hover { border-color: transparent; box-shadow: none; } .openclaw-workspace-card.settings-card > .settings-card-body, +.openclaw-summary-card > .settings-card-body, .openclaw-configs-card > .settings-card-body { width: 100%; min-width: 0; } +.openclaw-summary-card > .settings-card-body { + display: grid; + gap: 12px; + padding: 16px; + border: 1px solid rgba(137, 111, 94, 0.12); + border-radius: var(--radius-lg); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.86), rgba(249, 244, 239, 0.72)); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.openclaw-summary-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.summary-eyebrow { + font-size: 10px; + letter-spacing: 0.12em; + color: var(--color-text-tertiary); + font-weight: 600; +} + +.summary-title { + margin-top: 2px; + font-size: 16px; + font-weight: 600; + color: var(--color-text-primary); +} + +.summary-note, +.openclaw-diagnostic-note { + font-size: 11px; + color: var(--color-text-tertiary); + line-height: 1.45; +} + +.summary-note { + max-width: 260px; + text-align: right; +} + +.openclaw-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px; +} + +.openclaw-summary-item { + min-width: 0; + padding: 10px 12px; + border: 1px solid rgba(137, 111, 94, 0.10); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.72); +} + +.summary-item-label { + margin-bottom: 4px; + font-size: 11px; + color: var(--color-text-tertiary); +} + +.summary-item-value { + font-size: 13px; + font-weight: 500; + color: var(--color-text-primary); + overflow-wrap: anywhere; +} + +.openclaw-section-title { + margin-bottom: 8px; + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); +} + +.openclaw-quick-files { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} + +.openclaw-quick-file-btn { + padding: 7px 10px; + border: 1px solid rgba(137, 111, 94, 0.14); + border-radius: 999px; + background: rgba(255, 255, 255, 0.78); + color: var(--color-text-secondary); + font-size: 12px; + cursor: pointer; +} + +.openclaw-quick-file-btn:hover:not(:disabled) { + border-color: rgba(137, 111, 94, 0.24); + color: var(--color-text-primary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.openclaw-quick-file-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + /* Nested workspace card - distinguish from outer section */ .openclaw-workspace-card.settings-card > .settings-card-body > .openclaw-workspace-card { display: flex; @@ -563,6 +671,23 @@ overflow-wrap: anywhere; } +.openclaw-config-summary-mini { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} + +.openclaw-config-summary-mini span { + max-width: 100%; + padding: 3px 7px; + border-radius: 999px; + background: rgba(137, 111, 94, 0.08); + color: var(--color-text-tertiary); + font-size: 11px; + overflow-wrap: anywhere; +} + @media (min-width: 1200px) { .tool-icon { width: 42px; @@ -583,6 +708,15 @@ .workspace-input-group .btn-tool { width: 100%; } + + .openclaw-summary-header { + flex-direction: column; + } + + .summary-note { + max-width: none; + text-align: left; + } } @media (max-width: 520px) { From 3c684cb427b8f1f73d9d29fda17d0abbcf8e8368 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 13 Jul 2026 08:39:08 +0000 Subject: [PATCH 09/13] style(openclaw): tighten workspace editor copy --- web-ui/modules/app.methods.agents.mjs | 4 +- web-ui/modules/i18n/locales/zh.mjs | 8 +- .../index/modal-config-template-agents.html | 12 ++- .../partials/index/panel-config-openclaw.html | 4 +- web-ui/res/web-ui-render.precompiled.js | 18 +++-- web-ui/styles/modals-core.css | 79 +++++++++++++++++++ 6 files changed, 106 insertions(+), 19 deletions(-) diff --git a/web-ui/modules/app.methods.agents.mjs b/web-ui/modules/app.methods.agents.mjs index bfa075bc..10671af9 100644 --- a/web-ui/modules/app.methods.agents.mjs +++ b/web-ui/modules/app.methods.agents.mjs @@ -206,13 +206,13 @@ export function createAgentsMethods(options = {}) { this.agentsContext = 'openclaw-workspace'; this.agentsWorkspaceFileName = fileName; this.agentsModalTitle = tr('modal.agents.title.openclawWorkspaceFile', `OpenClaw 工作区文件: ${fileName}`, { fileName }); - this.agentsModalHint = tr('modal.agents.hint.openclawWorkspaceFile', `保存后会写入 OpenClaw Workspace 下的 ${fileName}。`, { fileName }); + this.agentsModalHint = tr('modal.agents.hint.openclawWorkspaceFile', `Workspace / ${fileName}`, { fileName }); return; } this.agentsContext = context === 'openclaw' ? 'openclaw' : 'codex'; if (this.agentsContext === 'openclaw') { this.agentsModalTitle = tr('modal.agents.title.openclaw', 'OpenClaw AGENTS.md 编辑器'); - this.agentsModalHint = tr('modal.agents.hint.openclaw', '保存后会写入 OpenClaw Workspace 下的 AGENTS.md。'); + this.agentsModalHint = tr('modal.agents.hint.openclaw', 'Workspace / AGENTS.md'); } else { this.agentsModalTitle = tr('modal.agents.title.default', 'AGENTS.md 编辑器'); this.agentsModalHint = tr('modal.agents.hint.default', '保存后会写入目标 AGENTS.md(与 config.toml 同级)。'); diff --git a/web-ui/modules/i18n/locales/zh.mjs b/web-ui/modules/i18n/locales/zh.mjs index 3cb69619..4a1565a2 100644 --- a/web-ui/modules/i18n/locales/zh.mjs +++ b/web-ui/modules/i18n/locales/zh.mjs @@ -942,9 +942,9 @@ const zh = Object.freeze({ 'modal.agents.hint.claudeProjectGlobal': '保存后会写入 ~/.claude/CLAUDE.md。', 'modal.agents.contentLabel.claudeProject': 'CLAUDE.md 内容', 'modal.agents.placeholder.claudeProject': '在这里编辑 CLAUDE.md', - 'modal.agents.hint.openclaw': '保存后会写入 OpenClaw Workspace 下的 AGENTS.md。', + 'modal.agents.hint.openclaw': 'Workspace / AGENTS.md', 'modal.agents.title.openclawWorkspaceFile': 'OpenClaw 工作区文件: {fileName}', - 'modal.agents.hint.openclawWorkspaceFile': '保存后会写入 OpenClaw Workspace 下的 {fileName}。', + 'modal.agents.hint.openclawWorkspaceFile': 'Workspace / {fileName}', 'config.url.unset': '未设 URL', 'config.model.unset': '未设置模型', 'config.badge.system': '系统', @@ -1514,11 +1514,11 @@ const zh = Object.freeze({ 'openclaw.applyHint': '写入 ~/.openclaw/openclaw.json,支持 JSON5。', 'openclaw.workspace.title': 'OpenClaw 工作区', 'openclaw.configs.hint': '选择常用配置,或进入编辑器维护完整 JSON5。', - 'openclaw.agents.hint': '读写 Workspace 的 AGENTS.md,默认路径 ~/.openclaw/workspace/AGENTS.md。', + 'openclaw.agents.hint': 'Workspace / AGENTS.md', 'openclaw.agents.open': '打开 AGENTS.md', 'openclaw.workspaceFile': '工作区文件', 'openclaw.workspace.placeholder': '例如: SOUL.md', - 'openclaw.workspace.hint': '仅限 Workspace 内的 .md 文件。', + 'openclaw.workspace.hint': 'Workspace .md', 'openclaw.workspace.open': '打开工作区文件', 'openclaw.configured': '已配置', 'openclaw.notConfigured': '未配置', diff --git a/web-ui/partials/index/modal-config-template-agents.html b/web-ui/partials/index/modal-config-template-agents.html index d36c0745..d96ed2da 100644 --- a/web-ui/partials/index/modal-config-template-agents.html +++ b/web-ui/partials/index/modal-config-template-agents.html @@ -117,8 +117,8 @@
-
diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js index ddb18424..1f20153b 100644 --- a/web-ui/res/web-ui-render.precompiled.js +++ b/web-ui/res/web-ui-render.precompiled.js @@ -2253,7 +2253,7 @@ return function render(_ctx, _cache) { _createElementVNode("div", { class: "summary-eyebrow" }, "OPENCLAW"), _createElementVNode("div", { class: "summary-title" }, "配置与本地诊断") ]), - _createElementVNode("div", { class: "summary-note" }, "只读摘要,不读取 runtime 聊天消息") + _createElementVNode("div", { class: "summary-note" }, "配置摘要") ]), _createElementVNode("div", { class: "openclaw-summary-grid" }, [ (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.getOpenclawStatusSummaryItems(), (item) => { @@ -2266,7 +2266,7 @@ return function render(_ctx, _cache) { ], 2 /* CLASS */)) }), 128 /* KEYED_FRAGMENT */)) ]), - _createElementVNode("div", { class: "openclaw-diagnostic-note" }, " Browser 状态来自配置文件摘要;Headless 表示它不是用户可见浏览器窗口。此页不展示群聊/私聊运行会话。 ") + _createElementVNode("div", { class: "openclaw-diagnostic-note" }, " 本页仅管理配置与工作区文件。 ") ]) ]), _createElementVNode("section", { class: "settings-card settings-card--wide openclaw-workspace-card" }, [ @@ -9225,8 +9225,10 @@ return function render(_ctx, _cache) { }, _toDisplayString(_ctx.agentsSaving ? (_ctx.agentsDiffVisible ? _ctx.t('common.saving') : _ctx.t('common.previewing')) : (_ctx.agentsDiffVisible ? _ctx.t('common.save') : _ctx.t('common.preview'))), 9 /* TEXT, PROPS */, ["onClick", "disabled"]) ]) ]), - _createElementVNode("div", { class: "modal-editor-body" }, [ - _createElementVNode("div", { class: "form-group" }, [ + _createElementVNode("div", { + class: _normalizeClass(["modal-editor-body", { 'modal-editor-body--openclaw-workspace': _ctx.agentsContext === 'openclaw-workspace' }]) + }, [ + _createElementVNode("div", { class: "form-group agents-target-file-group" }, [ _createElementVNode("label", { class: "form-label" }, _toDisplayString(_ctx.t('modal.agents.targetFile')), 1 /* TEXT */), _createElementVNode("div", { class: "form-hint" }, [ _createTextVNode(_toDisplayString(_ctx.agentsPath || _ctx.t('common.notLoaded')) + " ", 1 /* TEXT */), @@ -9235,8 +9237,10 @@ return function render(_ctx, _cache) { : _createCommentVNode("v-if", true) ]) ]), - _createElementVNode("div", { class: "form-group" }, [ - _createElementVNode("label", { class: "form-label" }, _toDisplayString(_ctx.t(_ctx.agentsContext === 'claude-project' ? 'modal.agents.contentLabel.claudeProject' : 'modal.agents.contentLabel')), 1 /* TEXT */), + _createElementVNode("div", { class: "form-group agents-content-group" }, [ + _createElementVNode("label", { class: "form-label" }, _toDisplayString(_ctx.agentsContext === 'openclaw-workspace' + ? `${_ctx.agentsWorkspaceFileName || '工作区文件'} 内容` + : _ctx.t(_ctx.agentsContext === 'claude-project' ? 'modal.agents.contentLabel.claudeProject' : 'modal.agents.contentLabel')), 1 /* TEXT */), (!_ctx.agentsLoading && (_ctx.hasAgentsContentChanged() || _ctx.agentsDiffVisible)) ? (_openBlock(), _createElementBlock("div", { key: 0, @@ -9334,7 +9338,7 @@ return function render(_ctx, _cache) { }, _toDisplayString(_ctx.t('diff.viewHint.preview')), 1 /* TEXT */)) ]) ]) - ]) + ], 2 /* CLASS */) ]) ], 8 /* PROPS */, ["onClick"])) : _createCommentVNode("v-if", true), diff --git a/web-ui/styles/modals-core.css b/web-ui/styles/modals-core.css index 29820cd3..67af3218 100644 --- a/web-ui/styles/modals-core.css +++ b/web-ui/styles/modals-core.css @@ -61,6 +61,85 @@ max-height: calc(100vh - 56px); } +.agents-modal:has(.modal-editor-body--openclaw-workspace) { + width: min(94vw, 1040px); + max-height: calc(100vh - 40px); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 246, 241, 0.96) 100%); +} + +.agents-modal:has(.modal-editor-body--openclaw-workspace) .modal-editor-header { + padding: 18px 20px 12px; + border-bottom: 1px solid rgba(137, 111, 94, 0.10); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(249, 244, 239, 0.82)); +} + +.agents-modal:has(.modal-editor-body--openclaw-workspace) .modal-title { + margin-bottom: 0; + font-size: 18px; + letter-spacing: -0.02em; +} + +.agents-modal:has(.modal-editor-body--openclaw-workspace) .modal-header-actions { + gap: 6px; +} + +.agents-modal .modal-editor-body--openclaw-workspace { + display: grid; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + gap: 14px; + padding: 16px 20px 20px; + overflow: hidden; +} + +.agents-modal:has(.modal-editor-body--openclaw-workspace) .agents-target-file-group { + align-self: start; + margin-bottom: 0; + padding: 14px; + border: 1px solid rgba(137, 111, 94, 0.12); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.72); +} + +.agents-modal:has(.modal-editor-body--openclaw-workspace) .agents-target-file-group .form-hint { + margin-top: 8px; + font-family: var(--font-family-mono); + font-size: 12px; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.agents-modal:has(.modal-editor-body--openclaw-workspace) .agents-content-group { + min-width: 0; + margin-bottom: 0; +} + +.agents-modal:has(.modal-editor-body--openclaw-workspace) .agents-content-group > .form-label { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + font-size: 13px; + font-weight: 600; +} + +.agents-modal:has(.modal-editor-body--openclaw-workspace) .template-editor, +.agents-modal:has(.modal-editor-body--openclaw-workspace) .agents-diff-editor { + min-height: min(64vh, 620px); + max-height: min(68vh, 680px); + border-radius: var(--radius-lg); + border-color: rgba(137, 111, 94, 0.14); + background: rgba(255, 255, 255, 0.88); + font-size: 13px; + line-height: 1.62; +} + +.agents-modal:has(.modal-editor-body--openclaw-workspace) .template-editor-warning { + margin-top: 8px; + padding: 10px 12px; + border-radius: var(--radius-md); + background: rgba(137, 111, 94, 0.06); +} + .modal-editor-header { margin-bottom: 0; padding: var(--spacing-md) var(--spacing-md) 0; From c8496d3382de2334091666b4c959dd98622ca68c Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 13 Jul 2026 08:50:28 +0000 Subject: [PATCH 10/13] style(openclaw): align workspace file cards --- web-ui/modules/app.methods.openclaw-core.mjs | 2 +- .../partials/index/panel-config-openclaw.html | 25 +++++++----- web-ui/res/web-ui-render.precompiled.js | 39 ++++++++++++------- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/web-ui/modules/app.methods.openclaw-core.mjs b/web-ui/modules/app.methods.openclaw-core.mjs index 19ced347..7f5fa415 100644 --- a/web-ui/modules/app.methods.openclaw-core.mjs +++ b/web-ui/modules/app.methods.openclaw-core.mjs @@ -549,7 +549,7 @@ export function createOpenclawCoreMethods() { }, getOpenclawQuickWorkspaceFiles() { - return ['AGENTS.md', 'SOUL.md', 'USER.md', 'TOOLS.md', 'HEARTBEAT.md']; + return ['SOUL.md', 'USER.md', 'TOOLS.md', 'HEARTBEAT.md']; }, openOpenclawQuickWorkspaceFile(fileName) { diff --git a/web-ui/partials/index/panel-config-openclaw.html b/web-ui/partials/index/panel-config-openclaw.html index 2076d7f7..0d17b930 100644 --- a/web-ui/partials/index/panel-config-openclaw.html +++ b/web-ui/partials/index/panel-config-openclaw.html @@ -34,24 +34,29 @@
-
工作区快捷文件
-
+
+ + -
-
- - -
- -
- - -
-
{{ t('openclaw.workspace.hint') }}
-
diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js index a01c3c6a..99cfd372 100644 --- a/web-ui/res/web-ui-render.precompiled.js +++ b/web-ui/res/web-ui-render.precompiled.js @@ -2315,29 +2315,7 @@ return function render(_ctx, _cache) { _createElementVNode("path", { d: "M9 18l6-6-6-6" }) ])) ], 8 /* PROPS */, ["onClick", "disabled"])) - }), 128 /* KEYED_FRAGMENT */)), - _createElementVNode("div", { class: "openclaw-workspace-card" }, [ - _createElementVNode("label", { - class: "workspace-label", - for: "openclaw-workspace-file" - }, _toDisplayString(_ctx.t('openclaw.workspaceFile')), 1 /* TEXT */), - _createElementVNode("div", { class: "workspace-input-group" }, [ - _withDirectives(_createElementVNode("input", { - id: "openclaw-workspace-file", - class: "form-input", - "onUpdate:modelValue": $event => ((_ctx.openclawWorkspaceFileName) = $event), - placeholder: _ctx.t('openclaw.workspace.placeholder') - }, null, 8 /* PROPS */, ["onUpdate:modelValue", "placeholder"]), [ - [_vModelText, _ctx.openclawWorkspaceFileName] - ]), - _createElementVNode("button", { - class: "btn-tool", - onClick: _ctx.openOpenclawWorkspaceEditor, - disabled: _ctx.loading || !!_ctx.initError || _ctx.agentsLoading - }, _toDisplayString(_ctx.agentsLoading ? _ctx.t('config.modelLoading') : _ctx.t('openclaw.workspace.open')), 9 /* TEXT, PROPS */, ["onClick", "disabled"]) - ]), - _createElementVNode("div", { class: "workspace-meta" }, _toDisplayString(_ctx.t('openclaw.workspace.hint')), 1 /* TEXT */) - ]) + }), 128 /* KEYED_FRAGMENT */)) ]) ]) ]), diff --git a/web-ui/styles/openclaw-structured.css b/web-ui/styles/openclaw-structured.css index 2c1491e0..a53e49d4 100644 --- a/web-ui/styles/openclaw-structured.css +++ b/web-ui/styles/openclaw-structured.css @@ -494,124 +494,6 @@ cursor: not-allowed; } -/* Nested workspace card - distinguish from outer section */ -.openclaw-workspace-card.settings-card > .settings-card-body > .openclaw-workspace-card { - display: flex; - flex-direction: column; - justify-content: center; - gap: 8px; - min-height: 70px; - padding: 14px 16px; - border: 1px solid rgba(137, 111, 94, 0.12); - border-radius: var(--radius-lg); - background: rgba(255, 255, 255, 0.76); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); -} - -/* Workspace tool button */ -.openclaw-tools-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 8px; - align-items: stretch; -} - -.openclaw-tool-btn { - min-width: 0; - display: flex; - align-items: center; - gap: 12px; - min-height: 70px; - padding: 14px 16px; - width: 100%; - border-radius: var(--radius-lg); - border: 1px solid rgba(137, 111, 94, 0.12); - background: rgba(255, 255, 255, 0.76); - cursor: pointer; - transition: all 0.2s ease; - text-align: left; - font-family: inherit; -} - -.openclaw-tool-btn:hover:not(:disabled) { - border-color: rgba(137, 111, 94, 0.18); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); - transform: translateY(-1px); -} - -.openclaw-tool-btn:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.tool-icon { - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - font-size: 20px; - background: rgba(200, 121, 99, 0.08); - border-radius: var(--radius-md); - flex-shrink: 0; -} - -.tool-content { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.tool-title { - font-size: 14px; - font-weight: 500; - color: var(--color-text-primary); -} - -.tool-meta { - font-size: 11px; - color: var(--color-text-tertiary); - line-height: 1.45; -} - -.tool-chevron { - width: 18px; - height: 18px; - color: var(--color-text-muted); - flex-shrink: 0; -} - -.workspace-label { - font-size: 13px; - font-weight: 500; - color: var(--color-text-primary); -} - -.workspace-input-group { - display: grid; - grid-template-columns: minmax(0, 1fr) max-content; - gap: 8px; - align-items: center; - width: 100%; -} - -.workspace-input-group .form-input { - min-height: 36px; - width: 100%; -} - -.workspace-input-group .btn-tool { - white-space: nowrap; -} - -.workspace-meta { - font-size: 11px; - color: var(--color-text-tertiary); - line-height: 1.45; -} - .openclaw-card-list { margin-bottom: 0; } @@ -701,13 +583,6 @@ grid-template-columns: 1fr; } - .workspace-input-group { - grid-template-columns: 1fr; - } - - .workspace-input-group .btn-tool { - width: 100%; - } .openclaw-summary-header { flex-direction: column; From de4649728e4be9f804061689ab347f1a022f69d0 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 13 Jul 2026 09:18:03 +0000 Subject: [PATCH 12/13] style(openclaw): simplify workspace card labels --- .../partials/index/panel-config-openclaw.html | 4 +- web-ui/res/web-ui-render.precompiled.js | 4 +- web-ui/styles/openclaw-structured.css | 83 ++++++++++++++----- 3 files changed, 64 insertions(+), 27 deletions(-) diff --git a/web-ui/partials/index/panel-config-openclaw.html b/web-ui/partials/index/panel-config-openclaw.html index 5970d763..755da4e0 100644 --- a/web-ui/partials/index/panel-config-openclaw.html +++ b/web-ui/partials/index/panel-config-openclaw.html @@ -39,7 +39,7 @@
📄
AGENTS.md
-
{{ agentsLoading ? t('config.modelLoading') : t('openclaw.agents.hint') }}
+
{{ agentsLoading ? t('config.modelLoading') : 'Workspace file' }}
@@ -56,7 +56,7 @@
📄
{{ fileName }}
-
Workspace / {{ fileName }}
+
Workspace file
diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js index 99cfd372..2db6ce5f 100644 --- a/web-ui/res/web-ui-render.precompiled.js +++ b/web-ui/res/web-ui-render.precompiled.js @@ -2280,7 +2280,7 @@ return function render(_ctx, _cache) { _createElementVNode("div", { class: "tool-icon" }, "📄"), _createElementVNode("div", { class: "tool-content" }, [ _createElementVNode("div", { class: "tool-title" }, "AGENTS.md"), - _createElementVNode("div", { class: "tool-meta" }, _toDisplayString(_ctx.agentsLoading ? _ctx.t('config.modelLoading') : _ctx.t('openclaw.agents.hint')), 1 /* TEXT */) + _createElementVNode("div", { class: "tool-meta" }, _toDisplayString(_ctx.agentsLoading ? _ctx.t('config.modelLoading') : 'Workspace file'), 1 /* TEXT */) ]), (_openBlock(), _createElementBlock("svg", { class: "tool-chevron", @@ -2303,7 +2303,7 @@ return function render(_ctx, _cache) { _createElementVNode("div", { class: "tool-icon" }, "📄"), _createElementVNode("div", { class: "tool-content" }, [ _createElementVNode("div", { class: "tool-title" }, _toDisplayString(fileName), 1 /* TEXT */), - _createElementVNode("div", { class: "tool-meta" }, "Workspace / " + _toDisplayString(fileName), 1 /* TEXT */) + _createElementVNode("div", { class: "tool-meta" }, "Workspace file") ]), (_openBlock(), _createElementBlock("svg", { class: "tool-chevron", diff --git a/web-ui/styles/openclaw-structured.css b/web-ui/styles/openclaw-structured.css index a53e49d4..678e4643 100644 --- a/web-ui/styles/openclaw-structured.css +++ b/web-ui/styles/openclaw-structured.css @@ -459,39 +459,76 @@ overflow-wrap: anywhere; } -.openclaw-section-title { - margin-bottom: 8px; - font-size: 13px; - font-weight: 600; +.openclaw-tools-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +} + +.openclaw-tool-btn { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + min-height: 74px; + padding: 14px 16px; + border: 1px solid rgba(137, 111, 94, 0.12); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.76); color: var(--color-text-primary); + text-align: left; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; } -.openclaw-quick-files { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 10px; +.openclaw-tool-btn:hover:not(:disabled) { + transform: translateY(-1px); + border-color: rgba(137, 111, 94, 0.22); + box-shadow: 0 8px 22px rgba(68, 52, 39, 0.08); } -.openclaw-quick-file-btn { - padding: 7px 10px; - border: 1px solid rgba(137, 111, 94, 0.14); - border-radius: 999px; - background: rgba(255, 255, 255, 0.78); - color: var(--color-text-secondary); - font-size: 12px; - cursor: pointer; +.openclaw-tool-btn:disabled { + opacity: 0.58; + cursor: not-allowed; } -.openclaw-quick-file-btn:hover:not(:disabled) { - border-color: rgba(137, 111, 94, 0.24); +.tool-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: var(--radius-md); + background: rgba(137, 111, 94, 0.08); + font-size: 19px; +} + +.tool-content { + min-width: 0; +} + +.tool-title { + font-size: 14px; + font-weight: 600; color: var(--color-text-primary); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + line-height: 1.25; + overflow-wrap: anywhere; } -.openclaw-quick-file-btn:disabled { - opacity: 0.55; - cursor: not-allowed; +.tool-meta { + margin-top: 4px; + font-size: 11px; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--color-text-tertiary); + white-space: nowrap; +} + +.tool-chevron { + width: 18px; + height: 18px; + color: var(--color-text-tertiary); } .openclaw-card-list { From aec7088ed23ec8d81c310fb2c41fb6e6e18d67c0 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 13 Jul 2026 13:31:46 +0000 Subject: [PATCH 13/13] chore(release): bump version to 0.1.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index aac2688a..e21dd343 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codexmate", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codexmate", - "version": "0.1.1", + "version": "0.1.2", "license": "Apache-2.0", "dependencies": { "@iarna/toml": "^2.2.5", diff --git a/package.json b/package.json index 1cc10ada..5bdfd05e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexmate", - "version": "0.1.1", + "version": "0.1.2", "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具", "main": "cli.js", "bin": {