@muyichengshayu/promptx 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.en.md +3 -3
  3. package/README.md +3 -3
  4. package/apps/server/src/apiErrors.js +29 -0
  5. package/apps/server/src/assetRoutes.js +13 -6
  6. package/apps/server/src/codexRoutes.js +11 -8
  7. package/apps/server/src/codexSessions.js +8 -9
  8. package/apps/server/src/db.js +2 -0
  9. package/apps/server/src/index.js +9 -6
  10. package/apps/server/src/relayClient.js +32 -1
  11. package/apps/server/src/repository.js +15 -7
  12. package/apps/server/src/runDispatchService.js +7 -6
  13. package/apps/server/src/runnerClient.js +17 -6
  14. package/apps/server/src/systemRoutes.js +7 -2
  15. package/apps/server/src/taskAutomation.js +46 -28
  16. package/apps/server/src/taskRoutes.js +49 -21
  17. package/apps/server/src/workspaceFiles.js +9 -10
  18. package/apps/web/dist/assets/CodexSessionManagerDialog-DRvITBqE.js +1 -0
  19. package/apps/web/dist/assets/TaskDiffReviewDialog-CkJpxeLZ.js +12 -0
  20. package/apps/web/dist/assets/WorkbenchSettingsDialog-uWxVDrKG.js +26 -0
  21. package/apps/web/dist/assets/WorkbenchView-C_zr7Daj.js +236 -0
  22. package/apps/web/dist/assets/{index-gJE-amF1.css → index-XmpKh7Q0.css} +1 -1
  23. package/apps/web/dist/assets/index-v6Y430fB.js +25 -0
  24. package/apps/web/dist/assets/{info-Cj7IWLiL.js → info-Cs3Xt3mr.js} +1 -1
  25. package/apps/web/dist/index.html +2 -2
  26. package/package.json +1 -1
  27. package/packages/shared/src/index.js +16 -0
  28. package/apps/web/dist/assets/CodexSessionManagerDialog-iKV71_Q0.js +0 -1
  29. package/apps/web/dist/assets/TaskDiffReviewDialog-RZU6cecW.js +0 -12
  30. package/apps/web/dist/assets/WorkbenchSettingsDialog-wBQni_hm.js +0 -26
  31. package/apps/web/dist/assets/WorkbenchView-CfS_BXaL.js +0 -236
  32. package/apps/web/dist/assets/index-kAN-5XeP.js +0 -25
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.15
4
+
5
+ - 修复右侧编辑区在输入、自动保存和后台状态刷新同时发生时,偶发被旧数据覆盖导致尾字丢失的问题;当前任务只要仍处于编辑态,就以本地输入为准。
6
+ - 调整编辑区 block 渲染标识,改用稳定 key,减少插入图片、导入文件或删除前置 block 后的焦点跳动、光标丢失与输入状态抖动。
7
+ - 优化编辑区滚动策略:输入过程中不再强制把内容区推到底部,长内容中间编辑时视口更稳定,只在需要时把当前输入框保持在可视区域内。
8
+ - 新增浏览器级 E2E,覆盖编辑中防覆盖、自动保存后防覆盖、空闲后恢复同步、长内容中间输入不跳底,以及删除前置 block 后焦点与内容稳定等真实场景。
9
+
10
+ ## 0.1.14
11
+
12
+ - 工作台、设置、项目管理、代码变更、编辑任务等核心界面补齐中英双语支持,并新增前端 `i18n` 基础设施,后续扩展语言成本更低。
13
+ - 服务端错误返回补齐稳定的 `messageKey`,前端优先按 key 翻译;同时把 Relay 诊断、自动化通知、任务设置等动态文本一并接入 locale,减少英文界面穿透中文的问题。
14
+ - 优化英文界面的文案长度与布局细节,收短 `Changes`、`Projects` 等标题,调整项目列表与代码变更面板在英文场景下的换行与排布表现。
15
+ - 补齐多语言相关自动化验证:修复前端错误格式化回归,更新页面级 E2E 基座,并验证最新 turn 展开、历史执行过程加载和滚动跟随等关键流程未受影响。
16
+
3
17
  ## 0.1.13
4
18
 
5
19
  - 加固 Relay 登录链路:登录与管理登录改为 `POST`,并新增登录限流,降低暴力尝试与口令暴露风险。
package/README.en.md CHANGED
@@ -60,15 +60,15 @@ promptx stop
60
60
 
61
61
  ### Workspace
62
62
 
63
- ![PromptX workspace](docs/assets/workbench-overview.jpg)
63
+ ![PromptX workspace](docs/assets/readme-workbench-glass.png)
64
64
 
65
65
  ### Settings
66
66
 
67
- ![PromptX settings](docs/assets/settings-panel.jpg)
67
+ ![PromptX settings](docs/assets/readme-settings-theme-glass.png)
68
68
 
69
69
  ### Mobile
70
70
 
71
- ![PromptX mobile](docs/assets/mobile-remote.jpg)
71
+ ![PromptX mobile](docs/assets/readme-mobile-remote-glass.png)
72
72
 
73
73
  ## Why It Helps
74
74
 
package/README.md CHANGED
@@ -60,15 +60,15 @@ promptx stop
60
60
 
61
61
  ### 工作台
62
62
 
63
- ![PromptX 工作台](docs/assets/workbench-overview.jpg)
63
+ ![PromptX 工作台](docs/assets/readme-workbench-glass.png)
64
64
 
65
65
  ### 设置
66
66
 
67
- ![PromptX 设置](docs/assets/settings-panel.jpg)
67
+ ![PromptX 设置](docs/assets/readme-settings-theme-glass.png)
68
68
 
69
69
  ### 手机端
70
70
 
71
- ![PromptX 手机端](docs/assets/mobile-remote.jpg)
71
+ ![PromptX 手机端](docs/assets/readme-mobile-remote-glass.png)
72
72
 
73
73
  ## 为什么好用
74
74
 
@@ -0,0 +1,29 @@
1
+ export function createApiError(messageKey, message, statusCode = 400, extras = {}) {
2
+ const error = new Error(String(message || '请求失败。'))
3
+ error.statusCode = Math.max(400, Number(statusCode) || 400)
4
+ if (messageKey) {
5
+ error.messageKey = String(messageKey)
6
+ }
7
+ Object.assign(error, extras)
8
+ return error
9
+ }
10
+
11
+ export function getApiErrorPayload(error, fallback = {}) {
12
+ const payload = error?.payload && typeof error.payload === 'object' ? error.payload : {}
13
+ const messageKey = String(
14
+ error?.messageKey
15
+ || payload?.messageKey
16
+ || fallback.messageKey
17
+ || ''
18
+ ).trim()
19
+ const message = String(
20
+ error?.message
21
+ || payload?.message
22
+ || fallback.message
23
+ || '请求失败。'
24
+ ).trim() || '请求失败。'
25
+
26
+ return messageKey
27
+ ? { ...payload, messageKey, message }
28
+ : { ...payload, message }
29
+ }
@@ -3,6 +3,7 @@ import path from 'node:path'
3
3
  import { pipeline } from 'node:stream/promises'
4
4
  import { Jimp } from 'jimp'
5
5
  import { nanoid } from 'nanoid'
6
+ import { createApiError } from './apiErrors.js'
6
7
 
7
8
  function registerAssetRoutes(app, options = {}) {
8
9
  const {
@@ -17,10 +18,10 @@ function registerAssetRoutes(app, options = {}) {
17
18
  app.post('/api/uploads', async (request, reply) => {
18
19
  const part = await request.file()
19
20
  if (!part) {
20
- return reply.code(400).send({ message: '没有收到上传文件。' })
21
+ return reply.code(400).send({ messageKey: 'errors.uploadFileMissing', message: '没有收到上传文件。' })
21
22
  }
22
23
  if (!String(part.mimetype || '').startsWith('image/')) {
23
- return reply.code(400).send({ message: '只支持上传图片文件。' })
24
+ return reply.code(400).send({ messageKey: 'errors.uploadImageOnly', message: '只支持上传图片文件。' })
24
25
  }
25
26
 
26
27
  const tempPath = createTempFilePath(tmpDir, part.filename)
@@ -58,13 +59,13 @@ function registerAssetRoutes(app, options = {}) {
58
59
  app.post('/api/imports/pdf', async (request, reply) => {
59
60
  const part = await request.file()
60
61
  if (!part) {
61
- return reply.code(400).send({ message: '没有收到 PDF 文件。' })
62
+ return reply.code(400).send({ messageKey: 'errors.pdfFileMissing', message: '没有收到 PDF 文件。' })
62
63
  }
63
64
 
64
65
  const fileName = normalizeUploadFileName(part.filename, 'task.pdf')
65
66
  const mimetype = String(part.mimetype || '').toLowerCase()
66
67
  if (mimetype !== 'application/pdf' && !fileName.toLowerCase().endsWith('.pdf')) {
67
- return reply.code(400).send({ message: '只支持导入 PDF 文件。' })
68
+ return reply.code(400).send({ messageKey: 'errors.pdfOnly', message: '只支持导入 PDF 文件。' })
68
69
  }
69
70
 
70
71
  const tempPath = createTempFilePath(tmpDir, fileName, '.pdf')
@@ -80,7 +81,10 @@ function registerAssetRoutes(app, options = {}) {
80
81
 
81
82
  if (!imported.blocks.length) {
82
83
  removeAssetFiles(createdAssets)
83
- return reply.code(422).send({ message: '没有从 PDF 中提取到可导入的文本或图片。' })
84
+ return reply.code(422).send({
85
+ messageKey: 'errors.pdfNoImportableContent',
86
+ message: '没有从 PDF 中提取到可导入的文本或图片。',
87
+ })
84
88
  }
85
89
 
86
90
  return reply.code(201).send({
@@ -90,7 +94,10 @@ function registerAssetRoutes(app, options = {}) {
90
94
  })
91
95
  } catch (error) {
92
96
  removeAssetFiles(error.createdAssets || createdAssets)
93
- throw error
97
+ throw createApiError(error?.messageKey || '', error?.message || 'PDF 导入失败。', error?.statusCode || 500, {
98
+ createdAssets: error?.createdAssets || createdAssets,
99
+ cause: error,
100
+ })
94
101
  } finally {
95
102
  fs.rmSync(tempPath, { force: true })
96
103
  }
@@ -102,7 +102,7 @@ function registerCodexRoutes(app, options = {}) {
102
102
  app.get('/api/codex/sessions/:sessionId/files/tree', async (request, reply) => {
103
103
  const session = getPromptxCodexSessionById(request.params.sessionId)
104
104
  if (!session) {
105
- return reply.code(404).send({ message: '没有找到对应的 PromptX 项目。' })
105
+ return reply.code(404).send({ messageKey: 'errors.sessionNotFound', message: '没有找到对应的 PromptX 项目。' })
106
106
  }
107
107
 
108
108
  return listWorkspaceTree(session.cwd, {
@@ -114,7 +114,7 @@ function registerCodexRoutes(app, options = {}) {
114
114
  app.get('/api/codex/sessions/:sessionId/files/search', async (request, reply) => {
115
115
  const session = getPromptxCodexSessionById(request.params.sessionId)
116
116
  if (!session) {
117
- return reply.code(404).send({ message: '没有找到对应的 PromptX 项目。' })
117
+ return reply.code(404).send({ messageKey: 'errors.sessionNotFound', message: '没有找到对应的 PromptX 项目。' })
118
118
  }
119
119
 
120
120
  return searchWorkspaceEntries(session.cwd, {
@@ -134,7 +134,7 @@ function registerCodexRoutes(app, options = {}) {
134
134
  app.patch('/api/codex/sessions/:sessionId', async (request, reply) => {
135
135
  const session = updatePromptxCodexSession(request.params.sessionId, request.body || {})
136
136
  if (!session) {
137
- return reply.code(404).send({ message: '没有找到对应的 PromptX 项目。' })
137
+ return reply.code(404).send({ messageKey: 'errors.sessionNotFound', message: '没有找到对应的 PromptX 项目。' })
138
138
  }
139
139
 
140
140
  broadcastServerEvent('sessions.changed', {
@@ -145,13 +145,16 @@ function registerCodexRoutes(app, options = {}) {
145
145
 
146
146
  app.delete('/api/codex/sessions/:sessionId', async (request, reply) => {
147
147
  if (getRunningCodexRunBySessionId(request.params.sessionId)) {
148
- return reply.code(409).send({ message: '当前项目正在执行中,请先停止后再删除。' })
148
+ return reply.code(409).send({
149
+ messageKey: 'errors.currentProjectDeleteWhileRunning',
150
+ message: '当前项目正在执行中,请先停止后再删除。',
151
+ })
149
152
  }
150
153
 
151
154
  const affectedTaskSlugs = clearTaskCodexSessionReferences(request.params.sessionId)
152
155
  const session = deletePromptxCodexSession(request.params.sessionId)
153
156
  if (!session) {
154
- return reply.code(404).send({ message: '没有找到对应的 PromptX 项目。' })
157
+ return reply.code(404).send({ messageKey: 'errors.sessionNotFound', message: '没有找到对应的 PromptX 项目。' })
155
158
  }
156
159
 
157
160
  broadcastServerEvent('sessions.changed', {
@@ -176,7 +179,7 @@ function registerCodexRoutes(app, options = {}) {
176
179
  app.post('/api/codex/runs/:runId/stop', async (request, reply) => {
177
180
  const runRecord = getCodexRunById(request.params.runId)
178
181
  if (!runRecord) {
179
- return reply.code(404).send({ message: '没有找到对应的执行记录。' })
182
+ return reply.code(404).send({ messageKey: 'errors.runNotFound', message: '没有找到对应的执行记录。' })
180
183
  }
181
184
 
182
185
  const stopResult = await runDispatchService.requestRunStop(request.params.runId, {
@@ -199,7 +202,7 @@ function registerCodexRoutes(app, options = {}) {
199
202
  app.get('/api/codex/runs/:runId/events', async (request, reply) => {
200
203
  const runRecord = getCodexRunById(request.params.runId)
201
204
  if (!runRecord) {
202
- return reply.code(404).send({ message: '没有找到对应的执行记录。' })
205
+ return reply.code(404).send({ messageKey: 'errors.runNotFound', message: '没有找到对应的执行记录。' })
203
206
  }
204
207
 
205
208
  return {
@@ -213,7 +216,7 @@ function registerCodexRoutes(app, options = {}) {
213
216
  app.get('/api/codex/runs/:runId/stream', async (request, reply) => {
214
217
  const runRecord = getCodexRunById(request.params.runId)
215
218
  if (!runRecord) {
216
- return reply.code(404).send({ message: '没有找到对应的执行记录。' })
219
+ return reply.code(404).send({ messageKey: 'errors.runNotFound', message: '没有找到对应的执行记录。' })
217
220
  }
218
221
 
219
222
  reply.hijack()
@@ -4,11 +4,10 @@ import { nanoid } from 'nanoid'
4
4
  import { normalizeAgentEngine } from '../../../packages/shared/src/index.js'
5
5
  import { all, get, run, transaction } from './db.js'
6
6
  import { assertAgentRunner } from './agents/index.js'
7
+ import { createApiError } from './apiErrors.js'
7
8
 
8
9
  function createHttpError(message, statusCode = 400) {
9
- const error = new Error(message)
10
- error.statusCode = statusCode
11
- return error
10
+ return createApiError('', message, statusCode)
12
11
  }
13
12
 
14
13
  function toCodexSession(row) {
@@ -45,7 +44,7 @@ function ensureAgentRunnerAvailable(engine) {
45
44
  try {
46
45
  assertAgentRunner(engine)
47
46
  } catch (error) {
48
- throw createHttpError(error.message || '当前执行引擎不可用。')
47
+ throw createApiError('errors.agentEngineUnavailable', error.message || '当前执行引擎不可用。')
49
48
  }
50
49
  }
51
50
 
@@ -62,17 +61,17 @@ function normalizeTitle(input = '', cwd = '') {
62
61
  export function normalizeCwd(input = '') {
63
62
  const cwd = String(input || '').trim()
64
63
  if (!cwd) {
65
- throw createHttpError('请先填写工作目录。')
64
+ throw createApiError('errors.cwdRequired', '请先填写工作目录。')
66
65
  }
67
66
 
68
67
  const resolved = path.resolve(cwd)
69
68
  if (!fs.existsSync(resolved)) {
70
- throw createHttpError('工作目录不存在,请重新确认。')
69
+ throw createApiError('errors.cwdNotFound', '工作目录不存在,请重新确认。')
71
70
  }
72
71
 
73
72
  const stats = fs.statSync(resolved)
74
73
  if (!stats.isDirectory()) {
75
- throw createHttpError('工作目录必须是文件夹。')
74
+ throw createApiError('errors.cwdMustBeDirectory', '工作目录必须是文件夹。')
76
75
  }
77
76
 
78
77
  return resolved
@@ -144,10 +143,10 @@ export function updatePromptxCodexSession(sessionId, patch = {}) {
144
143
  ensureAgentRunnerAvailable(nextEngine)
145
144
 
146
145
  if (existing.started && wantsCwd && nextCwd !== existing.cwd) {
147
- throw createHttpError('已启动的 PromptX 项目不能直接修改工作目录。', 409)
146
+ throw createApiError('errors.startedProjectCwdLocked', '已启动的 PromptX 项目不能直接修改工作目录。', 409)
148
147
  }
149
148
  if (existing.started && wantsEngine && nextEngine !== existing.engine) {
150
- throw createHttpError('已启动的 PromptX 项目不能直接切换执行引擎,请新建项目。', 409)
149
+ throw createApiError('errors.startedProjectEngineLocked', '已启动的 PromptX 项目不能直接切换执行引擎,请新建项目。', 409)
151
150
  }
152
151
 
153
152
  const title = Object.prototype.hasOwnProperty.call(patch, 'title')
@@ -183,6 +183,7 @@ function migrateToV1() {
183
183
  notification_webhook_url TEXT NOT NULL DEFAULT '',
184
184
  notification_secret TEXT NOT NULL DEFAULT '',
185
185
  notification_trigger_on TEXT NOT NULL DEFAULT 'completed',
186
+ notification_locale TEXT NOT NULL DEFAULT 'zh-CN',
186
187
  notification_message_mode TEXT NOT NULL DEFAULT 'summary',
187
188
  notification_last_status TEXT NOT NULL DEFAULT '',
188
189
  notification_last_error TEXT NOT NULL DEFAULT '',
@@ -338,6 +339,7 @@ function applyAdditiveSchemaPatches() {
338
339
  `ALTER TABLE tasks ADD COLUMN notification_webhook_url TEXT NOT NULL DEFAULT ''`,
339
340
  `ALTER TABLE tasks ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
340
341
  `ALTER TABLE tasks ADD COLUMN notification_trigger_on TEXT NOT NULL DEFAULT 'completed'`,
342
+ `ALTER TABLE tasks ADD COLUMN notification_locale TEXT NOT NULL DEFAULT 'zh-CN'`,
341
343
  `ALTER TABLE tasks ADD COLUMN notification_message_mode TEXT NOT NULL DEFAULT 'summary'`,
342
344
  `ALTER TABLE tasks ADD COLUMN notification_last_status TEXT NOT NULL DEFAULT ''`,
343
345
  `ALTER TABLE tasks ADD COLUMN notification_last_error TEXT NOT NULL DEFAULT ''`,
@@ -76,7 +76,8 @@ import {
76
76
  registerInternalRunnerRoutes,
77
77
  registerRealtimeRoutes,
78
78
  } from './internalRoutes.js'
79
- import { registerAssetRoutes } from './assetRoutes.js'
79
+ import { registerAssetRoutes } from './assetRoutes.js'
80
+ import { getApiErrorPayload } from './apiErrors.js'
80
81
  import { registerWebAppRoutes } from './webAppRoutes.js'
81
82
  import { createTempFilePath, normalizeUploadFileName } from './upload.js'
82
83
  import { importPdfBlocks } from './pdf.js'
@@ -417,11 +418,13 @@ registerWebAppRoutes(app, {
417
418
  webDistDir,
418
419
  })
419
420
 
420
- app.setErrorHandler((error, request, reply) => {
421
- request.log.error(error)
422
- const message = error.statusCode === 413 ? '文件太大了。' : error.message || '发生了意外错误。'
423
- reply.code(error.statusCode || 500).send({ message })
424
- })
421
+ app.setErrorHandler((error, request, reply) => {
422
+ request.log.error(error)
423
+ const payload = getApiErrorPayload(error, error.statusCode === 413
424
+ ? { messageKey: 'errors.fileTooLarge', message: '文件太大了。' }
425
+ : { messageKey: 'errors.unexpectedServerError', message: error.message || '发生了意外错误。' })
426
+ reply.code(error.statusCode || 500).send(payload)
427
+ })
425
428
 
426
429
  purgeExpiredContent(true)
427
430
 
@@ -33,10 +33,14 @@ function createDisabledStatus() {
33
33
  lastHeartbeatAt: '',
34
34
  lastCloseCode: 0,
35
35
  lastCloseReason: '',
36
+ lastCloseReasonCode: '',
36
37
  lastError: '',
38
+ lastErrorKey: '',
39
+ lastErrorParams: null,
37
40
  reconnectCount: 0,
38
41
  reconnectPaused: false,
39
42
  reconnectPausedReason: '',
43
+ reconnectPausedReasonCode: '',
40
44
  nextReconnectDelayMs: 0,
41
45
  recentEvents: [],
42
46
  }
@@ -244,10 +248,12 @@ function createRelayClient({
244
248
  updateStatus({
245
249
  reconnectPaused: true,
246
250
  reconnectPausedReason: reason || String(rawReason || '').trim(),
251
+ reconnectPausedReasonCode: String(rawReason || '').trim(),
247
252
  nextReconnectDelayMs: 0,
248
253
  })
249
254
  appendRecentEvent('reconnect_paused', {
250
255
  reason: reason || String(rawReason || '').trim() || 'unknown',
256
+ reasonCode: String(rawReason || '').trim() || 'unknown',
251
257
  })
252
258
  logWarn('[relay] 检测到不可重试错误,已暂停自动重连', getLogContext({
253
259
  reason: reason || String(rawReason || '').trim() || 'unknown',
@@ -408,6 +414,8 @@ function createRelayClient({
408
414
  return connect().catch((error) => {
409
415
  updateStatus({
410
416
  lastError: error?.message || 'Relay 连接失败。',
417
+ lastErrorKey: 'connect_failed',
418
+ lastErrorParams: null,
411
419
  })
412
420
  appendRecentEvent('connect_failed', {
413
421
  source,
@@ -463,8 +471,11 @@ function createRelayClient({
463
471
  updateStatus({
464
472
  reconnectPaused: false,
465
473
  reconnectPausedReason: '',
474
+ reconnectPausedReasonCode: '',
466
475
  nextReconnectDelayMs: 0,
467
476
  lastError: '',
477
+ lastErrorKey: '',
478
+ lastErrorParams: null,
468
479
  })
469
480
  pendingReconnectSource = source
470
481
  appendRecentEvent('reconnect_requested', {
@@ -598,6 +609,9 @@ function createRelayClient({
598
609
  lastCloseCode: 0,
599
610
  lastCloseReason: '',
600
611
  lastError: '',
612
+ lastCloseReasonCode: '',
613
+ lastErrorKey: '',
614
+ lastErrorParams: null,
601
615
  })
602
616
  appendRecentEvent('auth_ok', {
603
617
  tenantKey: String(message?.tenantKey || '').trim(),
@@ -651,16 +665,26 @@ function createRelayClient({
651
665
  const reconnectSource = pendingReconnectSource
652
666
  pendingReconnectSource = ''
653
667
  const { rawReason, closeReason } = parseCloseReason(reason)
668
+ const rawReasonCode = String(rawReason || '').trim()
654
669
  const nextError = closeReason && closeReason !== '配置已更新,正在重连'
655
670
  ? `${wasAuthenticated ? 'Relay 已断开' : 'Relay 连接被拒绝'}:${closeReason}`
656
671
  : (!wasAuthenticated && code && code !== 1000 ? `Relay 连接已关闭(code=${code})` : '')
672
+ const nextErrorKey = closeReason && closeReason !== '配置已更新,正在重连'
673
+ ? (wasAuthenticated ? 'disconnected' : 'rejected')
674
+ : (!wasAuthenticated && code && code !== 1000 ? 'closed_with_code' : '')
675
+ const nextErrorParams = nextErrorKey === 'closed_with_code'
676
+ ? { code: Number(code || 0) }
677
+ : (nextErrorKey ? { reasonCode: rawReasonCode || closeReason || '', code: Number(code || 0) } : null)
657
678
 
658
679
  updateStatus({
659
680
  connected: false,
660
681
  lastDisconnectedAt: nowIso(),
661
682
  lastCloseCode: Number(code || 0),
662
683
  lastCloseReason: closeReason,
663
- ...(nextError ? { lastError: nextError } : {}),
684
+ lastCloseReasonCode: rawReasonCode,
685
+ ...(nextError
686
+ ? { lastError: nextError, lastErrorKey: nextErrorKey, lastErrorParams: nextErrorParams }
687
+ : { lastErrorKey: '', lastErrorParams: null }),
664
688
  })
665
689
  appendRecentEvent('close', {
666
690
  code: Number(code || 0),
@@ -699,6 +723,8 @@ function createRelayClient({
699
723
  }
700
724
  updateStatus({
701
725
  lastError: error?.message || 'Relay 连接失败。',
726
+ lastErrorKey: 'connect_failed',
727
+ lastErrorParams: null,
702
728
  })
703
729
  appendRecentEvent('error', {
704
730
  error: error?.message || String(error || ''),
@@ -746,6 +772,8 @@ function createRelayClient({
746
772
  updateStatus({
747
773
  connected: false,
748
774
  nextReconnectDelayMs: 0,
775
+ lastErrorKey: '',
776
+ lastErrorParams: null,
749
777
  })
750
778
  appendRecentEvent('stopped')
751
779
  logInfo('[relay] 已停止', getLogContext())
@@ -759,8 +787,11 @@ function createRelayClient({
759
787
  syncStatusFromConfig()
760
788
  updateStatus({
761
789
  lastError: '',
790
+ lastErrorKey: '',
791
+ lastErrorParams: null,
762
792
  reconnectPaused: false,
763
793
  reconnectPausedReason: '',
794
+ reconnectPausedReasonCode: '',
764
795
  nextReconnectDelayMs: 0,
765
796
  })
766
797
  appendRecentEvent('config_updated', {
@@ -12,6 +12,7 @@ import {
12
12
  normalizeTaskAutomationConcurrencyPolicy,
13
13
  normalizeTaskAutomationTimezone,
14
14
  normalizeTaskNotificationChannel,
15
+ normalizeTaskNotificationLocale,
15
16
  normalizeTaskNotificationMessageMode,
16
17
  normalizeTaskNotificationTrigger,
17
18
  normalizeExpiry,
@@ -65,6 +66,7 @@ function toTask(row, blocks = [], options = {}) {
65
66
  webhookUrl: String(row.notification_webhook_url || ''),
66
67
  secret: String(row.notification_secret || ''),
67
68
  triggerOn: normalizeTaskNotificationTrigger(row.notification_trigger_on),
69
+ locale: normalizeTaskNotificationLocale(row.notification_locale),
68
70
  messageMode: normalizeTaskNotificationMessageMode(row.notification_message_mode),
69
71
  lastStatus: String(row.notification_last_status || ''),
70
72
  lastError: String(row.notification_last_error || ''),
@@ -90,6 +92,7 @@ function mapTaskNotificationSummary(row) {
90
92
  enabled: Boolean(Number(row.notification_enabled) || 0),
91
93
  channelType: normalizeTaskNotificationChannel(row.notification_channel_type),
92
94
  triggerOn: normalizeTaskNotificationTrigger(row.notification_trigger_on),
95
+ locale: normalizeTaskNotificationLocale(row.notification_locale),
93
96
  lastStatus: String(row.notification_last_status || ''),
94
97
  lastSentAt: String(row.notification_last_sent_at || ''),
95
98
  }
@@ -122,6 +125,7 @@ function normalizeNotificationInput(input = {}, fallback = {}) {
122
125
  webhookUrl: enabled ? clampText(input?.webhookUrl || '', 2000).trim() : '',
123
126
  secret: enabled ? clampText(input?.secret || '', 200).trim() : '',
124
127
  triggerOn: normalizeTaskNotificationTrigger(input?.triggerOn || fallback.triggerOn),
128
+ locale: normalizeTaskNotificationLocale(input?.locale || fallback.locale),
125
129
  messageMode: normalizeTaskNotificationMessageMode(input?.messageMode || fallback.messageMode),
126
130
  lastStatus: clampText(input?.lastStatus || fallback.lastStatus || '', 32).trim(),
127
131
  lastError: clampText(input?.lastError || fallback.lastError || '', 500).trim(),
@@ -359,7 +363,7 @@ export function listTasks(limit = 30) {
359
363
  const rows = all(
360
364
  `SELECT id, slug, title, auto_title, last_prompt_preview, todo_items_json, codex_session_id,
361
365
  automation_enabled, automation_cron, automation_timezone, automation_concurrency_policy, automation_last_triggered_at, automation_next_trigger_at,
362
- notification_enabled, notification_channel_type, notification_webhook_url, notification_secret, notification_trigger_on, notification_message_mode, notification_last_status, notification_last_error, notification_last_sent_at,
366
+ notification_enabled, notification_channel_type, notification_webhook_url, notification_secret, notification_trigger_on, notification_locale, notification_message_mode, notification_last_status, notification_last_error, notification_last_sent_at,
363
367
  visibility, expires_at, created_at, updated_at
364
368
  FROM tasks
365
369
  ORDER BY created_at DESC, id DESC
@@ -388,7 +392,7 @@ export function getTaskBySlug(slug) {
388
392
  const row = get(
389
393
  `SELECT id, slug, title, auto_title, last_prompt_preview, todo_items_json, codex_session_id,
390
394
  automation_enabled, automation_cron, automation_timezone, automation_concurrency_policy, automation_last_triggered_at, automation_next_trigger_at,
391
- notification_enabled, notification_channel_type, notification_webhook_url, notification_secret, notification_trigger_on, notification_message_mode, notification_last_status, notification_last_error, notification_last_sent_at,
395
+ notification_enabled, notification_channel_type, notification_webhook_url, notification_secret, notification_trigger_on, notification_locale, notification_message_mode, notification_last_status, notification_last_error, notification_last_sent_at,
392
396
  visibility, expires_at, created_at, updated_at
393
397
  FROM tasks
394
398
  WHERE slug = ?`,
@@ -424,10 +428,10 @@ export function createTask(input = {}) {
424
428
  `INSERT INTO tasks (
425
429
  slug, edit_token, title, auto_title, last_prompt_preview, todo_items_json, codex_session_id,
426
430
  automation_enabled, automation_cron, automation_timezone, automation_concurrency_policy, automation_last_triggered_at, automation_next_trigger_at,
427
- notification_enabled, notification_channel_type, notification_webhook_url, notification_secret, notification_trigger_on, notification_message_mode, notification_last_status, notification_last_error, notification_last_sent_at,
431
+ notification_enabled, notification_channel_type, notification_webhook_url, notification_secret, notification_trigger_on, notification_locale, notification_message_mode, notification_last_status, notification_last_error, notification_last_sent_at,
428
432
  visibility, expires_at, created_at, updated_at
429
433
  )
430
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ,
434
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ,
431
435
  [
432
436
  slug,
433
437
  editToken,
@@ -447,6 +451,7 @@ export function createTask(input = {}) {
447
451
  notification.webhookUrl,
448
452
  notification.secret,
449
453
  notification.triggerOn,
454
+ notification.locale,
450
455
  notification.messageMode,
451
456
  notification.lastStatus,
452
457
  notification.lastError,
@@ -469,7 +474,7 @@ export function updateTask(slug, input = {}) {
469
474
  const existing = get(
470
475
  `SELECT id, edit_token, title, auto_title, last_prompt_preview, todo_items_json, codex_session_id,
471
476
  automation_enabled, automation_cron, automation_timezone, automation_concurrency_policy, automation_last_triggered_at, automation_next_trigger_at,
472
- notification_enabled, notification_channel_type, notification_webhook_url, notification_secret, notification_trigger_on, notification_message_mode, notification_last_status, notification_last_error, notification_last_sent_at,
477
+ notification_enabled, notification_channel_type, notification_webhook_url, notification_secret, notification_trigger_on, notification_locale, notification_message_mode, notification_last_status, notification_last_error, notification_last_sent_at,
473
478
  visibility, expires_at
474
479
  FROM tasks
475
480
  WHERE slug = ?`,
@@ -524,6 +529,7 @@ export function updateTask(slug, input = {}) {
524
529
  webhookUrl: existing.notification_webhook_url,
525
530
  secret: existing.notification_secret,
526
531
  triggerOn: existing.notification_trigger_on,
532
+ locale: existing.notification_locale,
527
533
  messageMode: existing.notification_message_mode,
528
534
  lastStatus: existing.notification_last_status,
529
535
  lastError: existing.notification_last_error,
@@ -535,6 +541,7 @@ export function updateTask(slug, input = {}) {
535
541
  webhookUrl: existing.notification_webhook_url,
536
542
  secret: existing.notification_secret,
537
543
  triggerOn: existing.notification_trigger_on,
544
+ locale: existing.notification_locale,
538
545
  messageMode: existing.notification_message_mode,
539
546
  lastStatus: existing.notification_last_status,
540
547
  lastError: existing.notification_last_error,
@@ -551,7 +558,7 @@ export function updateTask(slug, input = {}) {
551
558
  `UPDATE tasks
552
559
  SET title = ?, auto_title = ?, last_prompt_preview = ?, todo_items_json = ?, codex_session_id = ?,
553
560
  automation_enabled = ?, automation_cron = ?, automation_timezone = ?, automation_concurrency_policy = ?, automation_last_triggered_at = ?, automation_next_trigger_at = ?,
554
- notification_enabled = ?, notification_channel_type = ?, notification_webhook_url = ?, notification_secret = ?, notification_trigger_on = ?, notification_message_mode = ?, notification_last_status = ?, notification_last_error = ?, notification_last_sent_at = ?,
561
+ notification_enabled = ?, notification_channel_type = ?, notification_webhook_url = ?, notification_secret = ?, notification_trigger_on = ?, notification_locale = ?, notification_message_mode = ?, notification_last_status = ?, notification_last_error = ?, notification_last_sent_at = ?,
555
562
  visibility = ?, expires_at = ?, updated_at = ?
556
563
  WHERE slug = ?`,
557
564
  [
@@ -571,6 +578,7 @@ export function updateTask(slug, input = {}) {
571
578
  notification.webhookUrl,
572
579
  notification.secret,
573
580
  notification.triggerOn,
581
+ notification.locale,
574
582
  notification.messageMode,
575
583
  notification.lastStatus,
576
584
  notification.lastError,
@@ -742,7 +750,7 @@ export function listAutomationEnabledTasks(limit = 200) {
742
750
  const rows = all(
743
751
  `SELECT id, slug, title, auto_title, last_prompt_preview, codex_session_id,
744
752
  automation_enabled, automation_cron, automation_timezone, automation_concurrency_policy, automation_last_triggered_at, automation_next_trigger_at,
745
- notification_enabled, notification_channel_type, notification_webhook_url, notification_secret, notification_trigger_on, notification_message_mode, notification_last_status, notification_last_error, notification_last_sent_at,
753
+ notification_enabled, notification_channel_type, notification_webhook_url, notification_secret, notification_trigger_on, notification_locale, notification_message_mode, notification_last_status, notification_last_error, notification_last_sent_at,
746
754
  visibility, expires_at, created_at, updated_at
747
755
  FROM tasks
748
756
  WHERE automation_enabled = 1
@@ -2,6 +2,7 @@ import {
2
2
  extractRunnerDispatchPatch,
3
3
  reconcileRunAfterRunnerDispatchError,
4
4
  } from './runnerDispatch.js'
5
+ import { createApiError } from './apiErrors.js'
5
6
 
6
7
  export function createRunDispatchService(options = {}) {
7
8
  const runnerClient = options.runnerClient
@@ -23,28 +24,28 @@ export function createRunDispatchService(options = {}) {
23
24
  const promptBlocks = Array.isArray(payload.promptBlocks) ? payload.promptBlocks : []
24
25
 
25
26
  if (!normalizedTaskSlug) {
26
- throw new Error('任务不存在。')
27
+ throw createApiError('errors.taskNotFound', '任务不存在。', 404)
27
28
  }
28
29
  if (!normalizedSessionId) {
29
- throw new Error('请先选择一个 PromptX 项目。')
30
+ throw createApiError('errors.sessionRequired', '请先选择一个 PromptX 项目。')
30
31
  }
31
32
  if (!normalizedPrompt) {
32
- throw new Error('没有可发送的提示词。')
33
+ throw createApiError('errors.noPromptToSend', '没有可发送的提示词。')
33
34
  }
34
35
 
35
36
  const task = getTaskBySlug(normalizedTaskSlug)
36
37
  if (!task || task.expired) {
37
- throw new Error('任务不存在。')
38
+ throw createApiError('errors.taskNotFound', '任务不存在。', 404)
38
39
  }
39
40
 
40
41
  const session = getPromptxCodexSessionById(normalizedSessionId)
41
42
  if (!session) {
42
- throw new Error('没有找到对应的 PromptX 项目。')
43
+ throw createApiError('errors.sessionNotFound', '没有找到对应的 PromptX 项目。', 404)
43
44
  }
44
45
 
45
46
  const runningRunOnSession = getRunningCodexRunBySessionId(normalizedSessionId)
46
47
  if (runningRunOnSession) {
47
- throw new Error('当前项目正在执行中,请等待完成后再发送。')
48
+ throw createApiError('errors.currentProjectRunning', '当前项目正在执行中,请等待完成后再发送。', 409)
48
49
  }
49
50
 
50
51
  const runRecord = createCodexRun({
@@ -1,4 +1,5 @@
1
1
  import { buildInternalAuthHeaders } from './internalAuth.js'
2
+ import { createApiError } from './apiErrors.js'
2
3
 
3
4
  const DEFAULT_RUNNER_HTTP_TIMEOUT_MS = Math.max(
4
5
  500,
@@ -16,10 +17,12 @@ function getDefaultRunnerBaseUrl() {
16
17
  }
17
18
 
18
19
  function createHttpError(status, payload, fallbackMessage) {
19
- const error = new Error(String(payload?.message || fallbackMessage || `HTTP ${status}`))
20
- error.statusCode = status
21
- error.payload = payload
22
- return error
20
+ return createApiError(
21
+ payload?.messageKey || '',
22
+ String(payload?.message || fallbackMessage || `HTTP ${status}`),
23
+ status,
24
+ { payload }
25
+ )
23
26
  }
24
27
 
25
28
  function createRequestAbortController(timeoutMs, upstreamSignal) {
@@ -73,9 +76,17 @@ export function createRunnerClient(options = {}) {
73
76
  } catch (error) {
74
77
  abortController.cleanup()
75
78
  if (abortController.wasTimeout()) {
76
- throw createHttpError(504, { message: `runner 请求超时(>${timeoutMs}ms)。` }, 'runner 请求超时。')
79
+ throw createHttpError(
80
+ 504,
81
+ { messageKey: 'errors.runnerRequestTimeout', message: `runner 请求超时(>${timeoutMs}ms)。` },
82
+ 'runner 请求超时。'
83
+ )
77
84
  }
78
- throw createHttpError(503, { message: error.message || '无法连接 runner 服务。' }, '无法连接 runner 服务。')
85
+ throw createHttpError(
86
+ 503,
87
+ { messageKey: 'errors.runnerServiceUnavailable', message: error.message || '无法连接 runner 服务。' },
88
+ '无法连接 runner 服务。'
89
+ )
79
90
  }
80
91
 
81
92
  const text = await response.text()