Skip to content

Commit c35d11e

Browse files
committed
chore(web-ui): localize settings and harden proxy streaming
Localize remaining Settings/Webhook/Trash/provider/toast UI labels, keep dynamic toast translation fallback behavior, refresh the precompiled Web UI render bundle, bump package metadata to 0.0.39, and retry built-in proxy streaming aborts before any client-visible SSE output.
1 parent 789a08e commit c35d11e

13 files changed

Lines changed: 576 additions & 105 deletions

cli/builtin-proxy.js

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ function createBuiltinProxyRuntimeController(deps = {}) {
134134
if (/ECONNRESET|ECONNREFUSED|EPIPE|EPROTO|ETIMEDOUT/i.test(text)) return true;
135135
if (/EAI_AGAIN/i.test(text)) return true;
136136
if (/UND_ERR_SOCKET/i.test(text)) return true;
137-
if (/disconnected before|secure tls|tls handshake/i.test(text)) return true;
137+
if (/aborted|stream aborted|disconnected before|secure tls|tls handshake/i.test(text)) return true;
138138
return false;
139139
}
140140

@@ -712,6 +712,7 @@ function createBuiltinProxyRuntimeController(deps = {}) {
712712
content: [{ type: 'output_text', text: '' }]
713713
};
714714
state.output.push(state.messageItem);
715+
beginChatStreamResponsesSse(state);
715716
writeSse(state.res, 'response.output_item.added', {
716717
type: 'response.output_item.added',
717718
output_index: state.output.length - 1,
@@ -764,6 +765,7 @@ function createBuiltinProxyRuntimeController(deps = {}) {
764765

765766
function finishChatStreamResponsesSse(state) {
766767
if (state.finished) return;
768+
beginChatStreamResponsesSse(state);
767769
state.finished = true;
768770
stopChatStreamHeartbeat(state);
769771

@@ -829,8 +831,36 @@ function createBuiltinProxyRuntimeController(deps = {}) {
829831
} catch (_) {}
830832
}
831833

834+
function beginChatStreamResponsesSse(state) {
835+
if (!state || state.started) return;
836+
state.started = true;
837+
const res = state.res;
838+
if (!res.headersSent) {
839+
res.writeHead(200, {
840+
'Content-Type': 'text/event-stream; charset=utf-8',
841+
'Cache-Control': 'no-cache',
842+
'Connection': 'keep-alive',
843+
'X-Accel-Buffering': 'no'
844+
});
845+
}
846+
startChatStreamHeartbeat(state);
847+
if (typeof res.on === 'function' && !state.closeListenerAttached) {
848+
state.closeListenerAttached = true;
849+
res.on('close', () => stopChatStreamHeartbeat(state));
850+
}
851+
writeSse(res, 'response.created', {
852+
type: 'response.created',
853+
response: {
854+
id: state.responseId,
855+
model: state.model,
856+
created_at: state.createdAt
857+
}
858+
});
859+
}
860+
832861
function failChatStreamResponsesSse(state, message) {
833862
if (!state || state.finished) return;
863+
beginChatStreamResponsesSse(state);
834864
state.finished = true;
835865
stopChatStreamHeartbeat(state);
836866
failResponsesSseRaw(state.res, message);
@@ -878,8 +908,12 @@ function createBuiltinProxyRuntimeController(deps = {}) {
878908
const handleAbort = (reason) => {
879909
if (settled) return;
880910
if (streamState) {
881-
failChatStreamResponsesSse(streamState, reason);
882-
finish({ ok: true });
911+
if (streamState.started) {
912+
failChatStreamResponsesSse(streamState, reason);
913+
finish({ ok: true });
914+
return;
915+
}
916+
finish({ ok: false, error: reason || 'upstream stream failed' });
883917
return;
884918
}
885919
if (res.headersSent) {
@@ -909,14 +943,13 @@ function createBuiltinProxyRuntimeController(deps = {}) {
909943
return;
910944
}
911945

912-
res.writeHead(200, {
913-
'Content-Type': 'text/event-stream; charset=utf-8',
914-
'Cache-Control': 'no-cache',
915-
'Connection': 'keep-alive',
916-
'X-Accel-Buffering': 'no'
917-
});
918-
919946
if (!/text\/event-stream/i.test(contentType)) {
947+
res.writeHead(200, {
948+
'Content-Type': 'text/event-stream; charset=utf-8',
949+
'Cache-Control': 'no-cache',
950+
'Connection': 'keep-alive',
951+
'X-Accel-Buffering': 'no'
952+
});
920953
upstreamRes.on('data', (chunk) => chunk && chunks.push(chunk));
921954
upstreamRes.on('end', () => {
922955
const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
@@ -946,24 +979,14 @@ function createBuiltinProxyRuntimeController(deps = {}) {
946979
messageText: '',
947980
toolCalls: [],
948981
finished: false,
982+
started: false,
983+
closeListenerAttached: false,
949984
nextSeq: () => {
950985
sequence += 1;
951986
return sequence;
952987
}
953988
};
954989
streamState = state;
955-
startChatStreamHeartbeat(state);
956-
if (typeof res.on === 'function') {
957-
res.on('close', () => stopChatStreamHeartbeat(state));
958-
}
959-
writeSse(res, 'response.created', {
960-
type: 'response.created',
961-
response: {
962-
id: state.responseId,
963-
model: state.model,
964-
created_at: state.createdAt
965-
}
966-
});
967990

968991
let buffer = '';
969992
const handleEventBlock = (block) => {

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codexmate",
3-
"version": "0.0.38",
3+
"version": "0.0.39",
44
"description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
55
"main": "cli.js",
66
"bin": {

tests/unit/builtin-proxy-responses-shim.test.mjs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,66 @@ test('builtin-proxy /v1/responses maps Responses tool items through chat fallbac
555555
}
556556
});
557557

558+
test('builtin-proxy /v1/responses stream=true retries chat fallback when upstream aborts before output', async () => {
559+
const sockets = new Set();
560+
let chatAttempts = 0;
561+
const upstream = http.createServer((req, res) => {
562+
if (req.url === '/v1/responses' && req.method === 'POST') {
563+
res.writeHead(404, { 'Content-Type': 'application/json' });
564+
res.end(JSON.stringify({ error: 'responses endpoint unavailable' }));
565+
return;
566+
}
567+
if (req.url === '/v1/chat/completions' && req.method === 'POST') {
568+
req.on('data', () => {});
569+
req.on('end', () => {
570+
chatAttempts += 1;
571+
res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8' });
572+
if (chatAttempts === 1) {
573+
res.write('data: {"id":"chatcmpl_retry","model":"gpt-test","choices":[{"delta":{"role":"assistant"}}]}\n\n');
574+
setTimeout(() => {
575+
try { req.socket.destroy(); } catch (_) {}
576+
}, 30);
577+
return;
578+
}
579+
res.write('data: {"id":"chatcmpl_retry","model":"gpt-test","choices":[{"delta":{"role":"assistant"}}]}\n\n');
580+
res.write('data: {"id":"chatcmpl_retry","model":"gpt-test","choices":[{"delta":{"content":"recovered-stream"}}]}\n\n');
581+
res.end('data: [DONE]\n\n');
582+
});
583+
return;
584+
}
585+
res.writeHead(404, { 'Content-Type': 'application/json' });
586+
res.end(JSON.stringify({ error: 'not found' }));
587+
});
588+
upstream.on('connection', (socket) => {
589+
sockets.add(socket);
590+
socket.on('close', () => sockets.delete(socket));
591+
});
592+
const { port: upstreamPort } = await listen(upstream);
593+
let proxyRuntime = null;
594+
595+
try {
596+
proxyRuntime = await startTestProxy(upstreamPort);
597+
const proxyPort = proxyRuntime.server.address().port;
598+
const sse = await requestText(`http://127.0.0.1:${proxyPort}/v1/responses`, {
599+
method: 'POST',
600+
headers: { 'Content-Type': 'application/json' },
601+
body: { model: 'gpt-test', input: 'ping', stream: true }
602+
});
603+
assert.equal(sse.status, 200);
604+
assert.ok(chatAttempts >= 2, 'pre-output stream abort should be retried');
605+
assert.match(sse.headers['content-type'], /text\/event-stream/i);
606+
assert.match(sse.text, /"delta":"recovered-stream"/);
607+
assert.match(sse.text, /event: response\.completed/);
608+
assert.doesNotMatch(sse.text, /event: response\.failed/);
609+
assert.equal((sse.text.match(/event: response\.created/g) || []).length, 1);
610+
} finally {
611+
if (proxyRuntime) {
612+
await closeServer(proxyRuntime.server, proxyRuntime.connections);
613+
}
614+
await closeServer(upstream, sockets);
615+
}
616+
});
617+
558618
test('builtin-proxy /v1/responses stream=true emits response.failed when upstream stream aborts mid-flight', async () => {
559619
const sockets = new Set();
560620
const upstream = http.createServer((req, res) => {

web-ui/modules/app.computed.main-tabs.mjs

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -43,93 +43,97 @@ function readTaskOrchestrationDraftMetrics(taskOrchestration) {
4343
};
4444
}
4545

46-
function createTaskDraftChecklist(metrics) {
46+
function translateTaskText(t, key, fallback, params = null) {
47+
return typeof t === 'function' ? t(key, params) : fallback;
48+
}
49+
50+
function createTaskDraftChecklist(metrics, t = null) {
4751
const workflowReady = metrics.engine !== 'workflow' || metrics.workflowCount > 0;
4852
const scopeReady = metrics.hasNotes || !metrics.allowWrite;
4953
const previewReady = metrics.hasPlan && metrics.planIssues.length === 0;
5054
return [
5155
{
5256
key: 'target',
53-
label: '目标',
57+
label: translateTaskText(t, 'orchestration.readiness.target.label', '目标'),
5458
done: metrics.hasTarget,
55-
detail: metrics.hasTarget ? '已写目标' : '还没写目标'
59+
detail: metrics.hasTarget ? translateTaskText(t, 'orchestration.readiness.target.done', '已写目标') : translateTaskText(t, 'orchestration.readiness.target.missing', '还没写目标')
5660
},
5761
{
5862
key: 'engine',
59-
label: metrics.engine === 'workflow' ? 'Workflow' : '执行策略',
63+
label: metrics.engine === 'workflow' ? 'Workflow' : translateTaskText(t, 'orchestration.readiness.engine.label', '执行策略'),
6064
done: workflowReady,
6165
detail: metrics.engine === 'workflow'
62-
? (metrics.workflowCount > 0 ? `已选 ${metrics.workflowCount} 个 Workflow` : '还没选 Workflow ID')
63-
: '使用 Codex 规划节点'
66+
? (metrics.workflowCount > 0 ? translateTaskText(t, 'orchestration.readiness.workflow.done', `已选 ${metrics.workflowCount} 个 Workflow`, { count: metrics.workflowCount }) : translateTaskText(t, 'orchestration.readiness.workflow.missing', '还没选 Workflow ID'))
67+
: translateTaskText(t, 'orchestration.readiness.engine.codex', '使用 Codex 规划节点')
6468
},
6569
{
6670
key: 'scope',
67-
label: '边界',
71+
label: translateTaskText(t, 'orchestration.readiness.scope.label', '边界'),
6872
done: scopeReady,
6973
detail: metrics.hasNotes
70-
? '已补充说明'
71-
: (metrics.allowWrite ? '建议补说明后再写入' : '当前是只读,可直接试')
74+
? translateTaskText(t, 'orchestration.readiness.scope.done', '已补充说明')
75+
: (metrics.allowWrite ? translateTaskText(t, 'orchestration.readiness.scope.writeHint', '建议补说明后再写入') : translateTaskText(t, 'orchestration.readiness.scope.readonlyHint', '当前是只读,可直接试'))
7276
},
7377
{
7478
key: 'preview',
75-
label: '预览',
79+
label: translateTaskText(t, 'orchestration.readiness.preview.label', '预览'),
7680
done: previewReady,
7781
detail: !metrics.hasPlan
78-
? '还没生成计划'
79-
: (metrics.planIssues.length > 0 ? `有 ${metrics.planIssues.length} 个阻塞项` : `计划可用,${metrics.planNodeCount} 个节点`)
82+
? translateTaskText(t, 'orchestration.readiness.preview.missing', '还没生成计划')
83+
: (metrics.planIssues.length > 0 ? translateTaskText(t, 'orchestration.readiness.preview.blocked', `有 ${metrics.planIssues.length} 个阻塞项`, { count: metrics.planIssues.length }) : translateTaskText(t, 'orchestration.readiness.preview.ready', `计划可用,${metrics.planNodeCount} 个节点`, { count: metrics.planNodeCount }))
8084
}
8185
];
8286
}
8387

84-
function createTaskDraftReadiness(metrics) {
88+
function createTaskDraftReadiness(metrics, t = null) {
8589
if (!metrics.hasTarget) {
8690
return {
8791
tone: 'neutral',
88-
title: '先写目标',
89-
summary: '先把想完成的结果写清楚,再让编排器拆节点。'
92+
title: translateTaskText(t, 'orchestration.readiness.empty.title', '先写目标'),
93+
summary: translateTaskText(t, 'orchestration.readiness.empty.summary', '先把想完成的结果写清楚,再让编排器拆节点。')
9094
};
9195
}
9296
if (metrics.engine === 'workflow' && metrics.workflowCount === 0) {
9397
return {
9498
tone: 'warn',
95-
title: '缺少 Workflow',
96-
summary: '你已经选了 Workflow 模式,但还没指定可复用流程。'
99+
title: translateTaskText(t, 'orchestration.readiness.workflow.title', '缺少 Workflow'),
100+
summary: translateTaskText(t, 'orchestration.readiness.workflow.summary', '你已经选了 Workflow 模式,但还没指定可复用流程。')
97101
};
98102
}
99103
if (!metrics.hasPlan) {
100104
return {
101105
tone: 'warn',
102-
title: '建议先预览',
103-
summary: '草稿已成形,先生成一次计划,确认节点和依赖再执行。'
106+
title: translateTaskText(t, 'orchestration.readiness.preview.title', '建议先预览'),
107+
summary: translateTaskText(t, 'orchestration.readiness.preview.summary', '草稿已成形,先生成一次计划,确认节点和依赖再执行。')
104108
};
105109
}
106110
if (metrics.planIssues.length > 0) {
107111
return {
108112
tone: 'error',
109-
title: '预览有阻塞',
110-
summary: `当前计划里还有 ${metrics.planIssues.length} 个阻塞项,先处理它们。`
113+
title: translateTaskText(t, 'orchestration.readiness.blocked.title', '预览有阻塞'),
114+
summary: translateTaskText(t, 'orchestration.readiness.blocked.summary', `当前计划里还有 ${metrics.planIssues.length} 个阻塞项,先处理它们。`, { count: metrics.planIssues.length })
111115
};
112116
}
113117
if (metrics.planWarnings.length > 0) {
114118
return {
115119
tone: 'warn',
116-
title: '可以执行,但有提醒',
117-
summary: `计划已生成,但还有 ${metrics.planWarnings.length} 条提醒值得先看一眼。`
120+
title: translateTaskText(t, 'orchestration.readiness.warn.title', '可以执行,但有提醒'),
121+
summary: translateTaskText(t, 'orchestration.readiness.warn.summary', `计划已生成,但还有 ${metrics.planWarnings.length} 条提醒值得先看一眼。`, { count: metrics.planWarnings.length })
118122
};
119123
}
120124
if (metrics.dryRun) {
121125
return {
122126
tone: 'success',
123-
title: '适合先预演',
124-
summary: '现在可以安全地跑一次仅预演,先看结果再决定是否真实执行。'
127+
title: translateTaskText(t, 'orchestration.readiness.dryRun.title', '适合先预演'),
128+
summary: translateTaskText(t, 'orchestration.readiness.dryRun.summary', '现在可以安全地跑一次仅预演,先看结果再决定是否真实执行。')
125129
};
126130
}
127131
return {
128132
tone: 'success',
129-
title: '可以执行',
133+
title: translateTaskText(t, 'orchestration.readiness.ready.title', '可以执行'),
130134
summary: metrics.followUpCount > 0
131-
? `主目标和收尾动作都已具备,可以直接执行或入队。`
132-
: '主目标已经够清楚了,可以直接执行或入队。'
135+
? translateTaskText(t, 'orchestration.readiness.ready.withFollowUps', `主目标和收尾动作都已具备,可以直接执行或入队。`)
136+
: translateTaskText(t, 'orchestration.readiness.ready.summary', '主目标已经够清楚了,可以直接执行或入队。')
133137
};
134138
}
135139

@@ -144,6 +148,7 @@ export function createMainTabsComputed() {
144148
if (this.mainTab === 'market') return this.t('kicker.market');
145149
if (this.mainTab === 'plugins') return this.t('kicker.plugins');
146150
if (this.mainTab === 'docs') return this.t('kicker.docs');
151+
if (this.mainTab === 'trash') return this.t('kicker.trash');
147152
return this.t('kicker.settings');
148153
},
149154
mainTabTitle() {
@@ -155,6 +160,7 @@ export function createMainTabsComputed() {
155160
if (this.mainTab === 'market') return this.t('title.market');
156161
if (this.mainTab === 'plugins') return this.t('title.plugins');
157162
if (this.mainTab === 'docs') return this.t('title.docs');
163+
if (this.mainTab === 'trash') return this.t('settings.trash.title');
158164
return this.t('title.settings');
159165
},
160166
mainTabSubtitle() {
@@ -166,6 +172,7 @@ export function createMainTabsComputed() {
166172
if (this.mainTab === 'market') return this.t('subtitle.market');
167173
if (this.mainTab === 'plugins') return this.t('subtitle.plugins');
168174
if (this.mainTab === 'docs') return this.t('subtitle.docs');
175+
if (this.mainTab === 'trash') return this.t('settings.trash.meta');
169176
return this.t('subtitle.settings');
170177
},
171178
taskOrchestrationSelectedRun() {
@@ -196,10 +203,10 @@ export function createMainTabsComputed() {
196203
return readTaskOrchestrationDraftMetrics(this.taskOrchestration);
197204
},
198205
taskOrchestrationDraftChecklist() {
199-
return createTaskDraftChecklist(this.taskOrchestrationDraftMetrics);
206+
return createTaskDraftChecklist(this.taskOrchestrationDraftMetrics, this.t && this.t.bind(this));
200207
},
201208
taskOrchestrationDraftReadiness() {
202-
return createTaskDraftReadiness(this.taskOrchestrationDraftMetrics);
209+
return createTaskDraftReadiness(this.taskOrchestrationDraftMetrics, this.t && this.t.bind(this));
203210
}
204211
};
205212
}

web-ui/modules/app.methods.providers.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ export function createProvidersMethods(options = {}) {
208208
const configured = !!(provider && provider.hasKey);
209209
return {
210210
configured,
211-
text: configured ? '已配置' : '未配置'
211+
text: configured ? this.t('common.configured') : this.t('common.notConfigured')
212212
};
213213
},
214214

0 commit comments

Comments
 (0)