diff --git a/cli/openai-bridge-retry.js b/cli/openai-bridge-retry.js index e2604b8c..b7060116 100644 --- a/cli/openai-bridge-retry.js +++ b/cli/openai-bridge-retry.js @@ -1,14 +1,20 @@ -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); + if (code === 408 || code === 409 || code === 425 || code === 429) return true; + return code >= 500 && code <= 599; } function isTransientNetworkError(error) { @@ -30,7 +36,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 @@ -43,9 +49,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; @@ -53,10 +62,9 @@ async function retryTransientRequest(executor, options = {}) { module.exports = { DEFAULT_BRIDGE_MAX_RETRIES, - MIN_BRIDGE_MAX_RETRIES, - MAX_BRIDGE_MAX_RETRIES, normalizeBridgeMaxRetries, isTransientNetworkError, + isTransientHttpStatus, getTransientRetryDelayMs, retryTransientRequest }; 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/cli/openai-bridge.js b/cli/openai-bridge.js index f56e3101..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}` })); @@ -514,8 +510,23 @@ 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. Close the socket instead of leaving the client waiting forever. + if (!res.destroyed && typeof res.destroy === 'function') { + res.destroy(e); + } + } + 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 })); } })(); @@ -543,7 +554,6 @@ module.exports = { extractChatCompletionResult, buildResponsesPayloadFromChatResult, retryTransientRequest, - normalizeBridgeMaxRetries, normalizeOpenaiUpstreamBaseUrl, extractResponsesOutputText, shouldFallbackFromUpstreamResponses, 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": { diff --git a/tests/unit/openai-bridge-upstream-responses.test.mjs b/tests/unit/openai-bridge-upstream-responses.test.mjs index f207671c..8402162b 100644 --- a/tests/unit/openai-bridge-upstream-responses.test.mjs +++ b/tests/unit/openai-bridge-upstream-responses.test.mjs @@ -93,12 +93,81 @@ 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' }); + 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'); + + 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, 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('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) => { + 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' }); @@ -113,8 +182,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,8 +200,74 @@ 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, 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 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' + } + } + }), '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(); @@ -1609,3 +1743,201 @@ 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 }); +}); + +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 }); +}); + +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 }); +}); 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/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/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/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.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/app.methods.openclaw-core.mjs b/web-ui/modules/app.methods.openclaw-core.mjs index 62e4f1d0..7f5fa415 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 ['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/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..4a1565a2 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 模板', @@ -944,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': '系统', @@ -1516,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 @@ -