Visual Journal deploy commited on
Commit
e4e0afe
·
1 Parent(s): 84810e9

Deploy 5554830 to Docker Space

Browse files
Files changed (44) hide show
  1. README.md +3 -3
  2. docs/ui/literary-young-women-workbench-design.md +11 -11
  3. public/hf-space-deploy-marker.json +3 -3
  4. src/app/api/deploy-marker/route.ts +1 -1
  5. src/app/api/image-delete/route.test.ts +4 -4
  6. src/app/api/image-delete/route.ts +3 -3
  7. src/app/api/image-retention/route.ts +5 -5
  8. src/app/layout.tsx +2 -2
  9. src/app/page-regressions.test.ts +44 -0
  10. src/app/page.tsx +164 -79
  11. src/app/share/[token]/page-regressions.test.ts +10 -0
  12. src/app/share/[token]/page.tsx +19 -10
  13. src/components/editing-form.test.tsx +8 -8
  14. src/components/editing-form.tsx +29 -29
  15. src/components/generation-form.test.tsx +8 -8
  16. src/components/generation-form.tsx +31 -31
  17. src/components/history-panel.test.tsx +42 -13
  18. src/components/history-panel.tsx +77 -43
  19. src/components/image-output.test.tsx +17 -2
  20. src/components/image-output.tsx +9 -7
  21. src/components/language-selector.test.tsx +83 -0
  22. src/components/language-selector.tsx +39 -0
  23. src/components/mode-toggle.test.tsx +1 -1
  24. src/components/password-dialog.tsx +2 -8
  25. src/components/share-dialog.test.tsx +1 -1
  26. src/components/workbench-pro-dock-panels.tsx +9 -14
  27. src/components/workbench-pro-dock.test.tsx +33 -0
  28. src/components/workbench-status-strip.test.tsx +27 -26
  29. src/components/workbench-status-strip.tsx +3 -3
  30. src/lib/i18n-contract.test.ts +250 -0
  31. src/lib/i18n.tsx +262 -136
  32. src/lib/image-display-labels.test.ts +69 -0
  33. src/lib/image-display-labels.ts +74 -0
  34. src/lib/share-api-error.test.ts +41 -0
  35. src/lib/share-api-error.ts +18 -0
  36. src/lib/share-client.test.ts +16 -0
  37. src/lib/share-client.ts +8 -3
  38. src/lib/streaming-batch.ts +1 -0
  39. src/lib/streaming-status-label.test.ts +22 -0
  40. src/lib/streaming-status-label.ts +3 -2
  41. src/lib/webui-image-retention-client.test.ts +3 -6
  42. src/lib/webui-image-retention-client.ts +2 -6
  43. src/lib/workbench-cost-label.test.ts +0 -16
  44. src/lib/workbench-cost-label.ts +0 -7
README.md CHANGED
@@ -16,7 +16,7 @@ app_port: 4783
16
  对外产品名称为“图像手记 / Visual Journal”。HF Space 已使用 `visual-journal` 名称;为保持已有部署和自动化客户端兼容,仓库包名、Docker 服务、环境变量、API 路径和 Skill 标识继续使用 `gpt-image-playground` 相关技术名称。
17
 
18
  <p align="center">
19
- <img src="https://raw.githubusercontent.com/MisonL/visual-journal/8e0aeb379b8278c02611e36b2e4760194d114644/readme-images/interface.jpg" alt="图像手记主界面" width="900"/>
20
  </p>
21
 
22
  ## 快速开始
@@ -71,8 +71,8 @@ Windows、macOS 和 Linux 也可分别使用 `start-windows.bat`、`start-macos.
71
  ## 界面预览
72
 
73
  <p align="center">
74
- <img src="https://raw.githubusercontent.com/MisonL/visual-journal/8e0aeb379b8278c02611e36b2e4760194d114644/readme-images/mask-creation.jpg" alt="遮罩编辑界面" width="49%"/>
75
- <img src="https://raw.githubusercontent.com/MisonL/visual-journal/8e0aeb379b8278c02611e36b2e4760194d114644/readme-images/history.jpg" alt="历史与费用面板" width="49%"/>
76
  </p>
77
 
78
  ## 配置
 
16
  对外产品名称为“图像手记 / Visual Journal”。HF Space 已使用 `visual-journal` 名称;为保持已有部署和自动化客户端兼容,仓库包名、Docker 服务、环境变量、API 路径和 Skill 标识继续使用 `gpt-image-playground` 相关技术名称。
17
 
18
  <p align="center">
19
+ <img src="https://raw.githubusercontent.com/MisonL/visual-journal/555483031327561b81a266eb15c58ed3019536b7/readme-images/interface.jpg" alt="图像手记主界面" width="900"/>
20
  </p>
21
 
22
  ## 快速开始
 
71
  ## 界面预览
72
 
73
  <p align="center">
74
+ <img src="https://raw.githubusercontent.com/MisonL/visual-journal/555483031327561b81a266eb15c58ed3019536b7/readme-images/mask-creation.jpg" alt="遮罩编辑界面" width="49%"/>
75
+ <img src="https://raw.githubusercontent.com/MisonL/visual-journal/555483031327561b81a266eb15c58ed3019536b7/readme-images/history.jpg" alt="历史与费用面板" width="49%"/>
76
  </p>
77
 
78
  ## 配置
docs/ui/literary-young-women-workbench-design.md CHANGED
@@ -103,7 +103,7 @@
103
 
104
  ```text
105
  +-------------------------------------------------------------+
106
- | 顶部状态栏:图像手记 / 模型 / 渠道 / 费用 / 设置 |
107
  +--------------+------------------------------+---------------+
108
  | 左侧创作单 | 中央画面预览 | 右侧相册记录 |
109
  | 模式入口 | 生成状态 | 灵感相册 |
@@ -132,8 +132,8 @@
132
  - 产品名:`图像手记 / Visual Journal`
133
  - 副标题:`今天想做什么画面?`
134
  - 当前模型:例如 `gpt-image-2`
135
- - 渠道状态:例如 `流式可用`
136
- - 费用状态:例如 `预计 0.12 积分`
137
  - API 状态:例如 `API 正常`
138
  - 设置入口。
139
 
@@ -224,13 +224,13 @@
224
 
225
  ### 6.5 生成动作区
226
 
227
- 生成动作区必须贴近费用、模型和渠道状态,避免用户点按钮前不清楚和路径
228
 
229
  内容:
230
 
231
  - 状态 chip:`gpt-image-2`
232
- - 状态 chip:`流式可用`
233
- - 费用 chip:`预计 0.12 积分`
234
  - 主按钮:`生成图像`
235
  - 次级操作:`存为灵感`
236
  - 次级操作:`随便来点`
@@ -238,7 +238,7 @@
238
  要求:
239
 
240
  - `生成图像` 使用珊瑚番茄红。
241
- - 费用和模型信息必须在按钮附近。
242
  - 禁用状态要说明原因,例如缺少提示词、图片未上传、尺寸无效。
243
 
244
  ## 7. 中央画面预览
@@ -370,7 +370,7 @@
370
  - 主界面只显示高频参数。
371
  - 专业模式可展开,但不要一次铺满所有字段。
372
  - 工程词在主界面减少暴露,专业模式中可保留必要精确项。
373
- - 当前选择会影响费用或稳定性时,应显示解释。
374
  - 当前实现中,桌面端高级设置位于底部 Pro Dock。`省心模式` 展示模型、流式、格式和尺寸摘要;启用并发批量时,流式摘要必须显示并发状态。
375
  - 当前实现中,`专业模式` 包含 `输出`、`模型`、`流式`、`路由` 分组。`并发批量` 开关位于流式分组;当流式策略关闭、任务数不足或服务端容量不可用时,开关保持禁用并显示原因。
376
 
@@ -385,7 +385,7 @@
385
  1. 写提示词。
386
  2. 选择风格标签。
387
  3. 调整尺寸、数量、清晰度和格式。
388
- 4. 确认模型、状态预计费用
389
  5. 点击 `生成图像`。
390
  6. 在中央查看结果。
391
  7. 下载、编辑、变体、复用或对比。
@@ -424,7 +424,7 @@
424
  - 支持暂停、重试、复用失败项。
425
  - 结果按批次进入最近生成。
426
  - 当前实现中,批量模式按“每行提示词一条任务”执行,每条任务使用当前统一的尺寸、清晰度、格式、模型、流式和路由设置。
427
- - 批量模式默认顺序执行。只有用户显式启用 `并发批量`,且当前任务数大于 1、流式策略可用、服务端或用户自填 API Key 有并发容量时,才会并发执行。
428
  - 暂停批量时,已开始任务继续完成,未开始任务保留为失败项,便于用户复用失败项后再次提交。
429
  - 底部提示词动作必须使用当前可见的批量提示词文本,不得回退到隐藏的单条文生图提示词。
430
 
@@ -537,7 +537,7 @@
537
  功能验收:
538
 
539
  - 文生图、图生图、批量、复用历史入口清晰。
540
- - 用户点生成前能看到模型、渠道预计费用
541
  - 生成后能直接下载、继续编辑、做变体、复用提示词和对比。
542
  - 灵感相册与最近生成概念分离。
543
  - 高级参数默认收纳,但可被专业用户找到。
 
103
 
104
  ```text
105
  +-------------------------------------------------------------+
106
+ | 顶部状态栏:图像手记 / 模型 / 请求方式 / 请求数量 / 设置 |
107
  +--------------+------------------------------+---------------+
108
  | 左侧创作单 | 中央画面预览 | 右侧相册记录 |
109
  | 模式入口 | 生成状态 | 灵感相册 |
 
132
  - 产品名:`图像手记 / Visual Journal`
133
  - 副标题:`今天想做什么画面?`
134
  - 当前模型:例如 `gpt-image-2`
135
+ - 请求方式:例如 `自动`
136
+ - 请求数量:例如 `请求 1 张图片`
137
  - API 状态:例如 `API 正常`
138
  - 设置入口。
139
 
 
224
 
225
  ### 6.5 生成动作区
226
 
227
+ 生成动作区必须贴近模型、路由、请求方式请求数量,避免用户点按钮前不清楚本次请求的配置与范围
228
 
229
  内容:
230
 
231
  - 状态 chip:`gpt-image-2`
232
+ - 状态 chip:`自动`
233
+ - 请求数量 chip:`请求 1 张图片`
234
  - 主按钮:`生成图像`
235
  - 次级操作:`存为灵感`
236
  - 次级操作:`随便来点`
 
238
  要求:
239
 
240
  - `生成图像` 使用珊瑚番茄红。
241
+ - 模型、请求方式和请求数量信息必须在按钮附近。
242
  - 禁用状态要说明原因,例如缺少提示词、图片未上传、尺寸无效。
243
 
244
  ## 7. 中央画面预览
 
370
  - 主界面只显示高频参数。
371
  - 专业模式可展开,但不要一次铺满所有字段。
372
  - 工程词在主界面减少暴露,专业模式中可保留必要精确项。
373
+ - 当前选择会影响输出、请求方式或稳定性时,应显示解释。
374
  - 当前实现中,桌面端高级设置位于底部 Pro Dock。`省心模式` 展示模型、流式、格式和尺寸摘要;启用并发批量时,流式摘要必须显示并发状态。
375
  - 当前实现中,`专业模式` 包含 `输出`、`模型`、`流式`、`路由` 分组。`并发批量` 开关位于流式分组;当流式策略关闭、任务数不足或服务端容量不可用时,开关保持禁用并显示原因。
376
 
 
385
  1. 写提示词。
386
  2. 选择风格标签。
387
  3. 调整尺寸、数量、清晰度和格式。
388
+ 4. 确认模型、请求方式和请求图片数量
389
  5. 点击 `生成图像`。
390
  6. 在中央查看结果。
391
  7. 下载、编辑、变体、复用或对比。
 
424
  - 支持暂停、重试、复用失败项。
425
  - 结果按批次进入最近生成。
426
  - 当前实现中,批量模式按“每行提示词一条任务”执行,每条任务使用当前统一的尺寸、清晰度、格式、模型、流式和路由设置。
427
+ - 批量模式默认顺序执行。只有用户显式启用 `并发批量`,且当前任务数大于 1、当前请求方式支持流式传输、服务端或用户自填 API Key 有并发容量时,才会并发执行。
428
  - 暂停批量时,已开始任务继续完成,未开始任务保留为失败项,便于用户复用失败项后再次提交。
429
  - 底部提示词动作必须使用当前可见的批量提示词文本,不得回退到隐藏的单条文生图提示词。
430
 
 
537
  功能验收:
538
 
539
  - 文生图、图生图、批量、复用历史入口清晰。
540
+ - 用户点生成前能看到模型、路由、请求方式请求图片数量
541
  - 生成后能直接下载、继续编辑、做变体、复用提示词和对比。
542
  - 灵感相册与最近生成概念分离。
543
  - 高级参数默认收纳,但可被专业用户找到。
public/hf-space-deploy-marker.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "schema_version": 1,
3
- "local_sha": "8e0aeb379b8278c02611e36b2e4760194d114644",
4
- "created_at": "2026-07-28T10:50:06.797Z",
5
- "deploy_id": "8937be04-9c6c-4ac3-a678-850c8ced4dd2"
6
  }
 
1
  {
2
  "schema_version": 1,
3
+ "local_sha": "555483031327561b81a266eb15c58ed3019536b7",
4
+ "created_at": "2026-07-29T04:53:28.602Z",
5
+ "deploy_id": "e74cf06b-8aed-4779-b443-772bcb0ee272"
6
  }
src/app/api/deploy-marker/route.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { NextResponse } from 'next/server';
2
 
3
- const deployMarker = {"schema_version":1,"local_sha":"8e0aeb379b8278c02611e36b2e4760194d114644","created_at":"2026-07-28T10:50:06.797Z","deploy_id":"8937be04-9c6c-4ac3-a678-850c8ced4dd2"} as const;
4
 
5
  export const dynamic = 'force-dynamic';
6
 
 
1
  import { NextResponse } from 'next/server';
2
 
3
+ const deployMarker = {"schema_version":1,"local_sha":"555483031327561b81a266eb15c58ed3019536b7","created_at":"2026-07-29T04:53:28.602Z","deploy_id":"e74cf06b-8aed-4779-b443-772bcb0ee272"} as const;
4
 
5
  export const dynamic = 'force-dynamic';
6
 
src/app/api/image-delete/route.test.ts CHANGED
@@ -30,7 +30,7 @@ afterEach(async () => {
30
  });
31
 
32
  describe('POST /api/image-delete', { concurrency: false }, () => {
33
- it('removes the permanent marker after deleting an image successfully', async () => {
34
  const filepath = await writeOutputFile(validFilename);
35
  const store = await getWebuiImageRetentionStore();
36
  await store.preserve([validFilename]);
@@ -46,7 +46,7 @@ describe('POST /api/image-delete', { concurrency: false }, () => {
46
  assert.deepEqual(await store.listPermanentFilenames(), []);
47
  });
48
 
49
- it('reports a missing file as absent and releases its stale permanent marker', async () => {
50
  const store = await getWebuiImageRetentionStore();
51
  await store.preserve([missingFilename]);
52
 
@@ -104,7 +104,7 @@ describe('POST /api/image-delete', { concurrency: false }, () => {
104
  success: false,
105
  fileDeleted: true,
106
  markerRemoved: false,
107
- error: '图片已删除,但永久存状态清理失败。'
108
  }
109
  ]);
110
  await assert.rejects(() => access(filepath));
@@ -142,7 +142,7 @@ describe('POST /api/image-delete', { concurrency: false }, () => {
142
  success: false,
143
  fileAbsent: true,
144
  markerRemoved: false,
145
- error: '图片已不存在,但永久存状态清理失败。'
146
  }
147
  ]);
148
  assert.deepEqual(await store.listPermanentFilenames(), [missingFilename]);
 
30
  });
31
 
32
  describe('POST /api/image-delete', { concurrency: false }, () => {
33
+ it('removes automatic-cleanup protection after deleting an image successfully', async () => {
34
  const filepath = await writeOutputFile(validFilename);
35
  const store = await getWebuiImageRetentionStore();
36
  await store.preserve([validFilename]);
 
46
  assert.deepEqual(await store.listPermanentFilenames(), []);
47
  });
48
 
49
+ it('reports a missing file as absent and releases stale automatic-cleanup protection', async () => {
50
  const store = await getWebuiImageRetentionStore();
51
  await store.preserve([missingFilename]);
52
 
 
104
  success: false,
105
  fileDeleted: true,
106
  markerRemoved: false,
107
+ error: '图片已删除,但自动清理护未能清理。'
108
  }
109
  ]);
110
  await assert.rejects(() => access(filepath));
 
142
  success: false,
143
  fileAbsent: true,
144
  markerRemoved: false,
145
+ error: '图片已不存在,但自动清理护未能清理。'
146
  }
147
  ]);
148
  assert.deepEqual(await store.listPermanentFilenames(), [missingFilename]);
src/app/api/image-delete/route.ts CHANGED
@@ -75,7 +75,7 @@ export async function POST(request: NextRequest) {
75
 
76
  return NextResponse.json(
77
  {
78
- message: allSucceeded ? '所有文件已删除。' : '部分文件未能完整删除或清理永久存状态。',
79
  results: deletionResults
80
  },
81
  { status: allSucceeded ? 200 : 207 } // 部分失败时返回 207 Multi-Status。
@@ -99,13 +99,13 @@ async function deleteImageAndRetentionMarker(filename: string, outputDir: string
99
  try {
100
  await (await getWebuiImageRetentionStore()).remove([filename]);
101
  } catch (error) {
102
- appLogger.error('删除图片后清理永久存标记失败。', error);
103
  return {
104
  filename,
105
  success: false,
106
  ...(fileDeleted ? { fileDeleted: true } : { fileAbsent: true }),
107
  markerRemoved: false,
108
- error: fileDeleted ? '图片已删除,但永久存状态清理失败。' : '图片已不存在,但永久存状态清理失败。'
109
  };
110
  }
111
 
 
75
 
76
  return NextResponse.json(
77
  {
78
+ message: allSucceeded ? '所有文件已删除。' : '部分文件未能完整删除或清理自动清理。',
79
  results: deletionResults
80
  },
81
  { status: allSucceeded ? 200 : 207 } // 部分失败时返回 207 Multi-Status。
 
99
  try {
100
  await (await getWebuiImageRetentionStore()).remove([filename]);
101
  } catch (error) {
102
+ appLogger.error('删除图片后清理自动清理失败。', error);
103
  return {
104
  filename,
105
  success: false,
106
  ...(fileDeleted ? { fileDeleted: true } : { fileAbsent: true }),
107
  markerRemoved: false,
108
+ error: fileDeleted ? '图片已删除,但自动清理护未能清理。' : '图片已不存在,但自动清理护未能清理。'
109
  };
110
  }
111
 
src/app/api/image-retention/route.ts CHANGED
@@ -30,8 +30,8 @@ export async function GET(request: NextRequest) {
30
  const store = await getWebuiImageRetentionStore();
31
  return NextResponse.json({ filenames: await store.listPermanentFilenames() });
32
  } catch (error) {
33
- appLogger.error('读取永久存图片状态失败。', error);
34
- return NextResponse.json({ error: '读取永久存图片状态失败。' }, { status: 500 });
35
  }
36
  }
37
 
@@ -51,8 +51,8 @@ export async function POST(request: NextRequest) {
51
  const results = await applyRetentionRequest(body);
52
  return NextResponse.json({ results }, { status: results.every((result) => result.success) ? 200 : 207 });
53
  } catch (error) {
54
- appLogger.error('更新永久存图片状态失败。', error);
55
- return NextResponse.json({ error: '更新永久存图片状态失败。' }, { status: 500 });
56
  }
57
  }
58
 
@@ -160,7 +160,7 @@ async function validatePreservedFile(filename: string, outputDir: string): Promi
160
  return undefined;
161
  } catch (error) {
162
  if (isMissingFileError(error)) return '文件不存在。';
163
- appLogger.error('校验永久图片文件失败。', error);
164
  return '读取文件失败。';
165
  }
166
  }
 
30
  const store = await getWebuiImageRetentionStore();
31
  return NextResponse.json({ filenames: await store.listPermanentFilenames() });
32
  } catch (error) {
33
+ appLogger.error('读取自动清理状态失败。', error);
34
+ return NextResponse.json({ error: '读取自动清理状态失败。' }, { status: 500 });
35
  }
36
  }
37
 
 
51
  const results = await applyRetentionRequest(body);
52
  return NextResponse.json({ results }, { status: results.every((result) => result.success) ? 200 : 207 });
53
  } catch (error) {
54
+ appLogger.error('更新自动清理状态失败。', error);
55
+ return NextResponse.json({ error: '更新自动清理状态失败。' }, { status: 500 });
56
  }
57
  }
58
 
 
160
  return undefined;
161
  } catch (error) {
162
  if (isMissingFileError(error)) return '文件不存在。';
163
+ appLogger.error('校验自动清理图片文件失败。', error);
164
  return '读取文件失败。';
165
  }
166
  }
src/app/layout.tsx CHANGED
@@ -4,8 +4,8 @@ import { I18nProvider } from '@/lib/i18n';
4
  import type { Metadata } from 'next';
5
 
6
  export const metadata: Metadata = {
7
- title: '图像手记 | Visual Journal',
8
- description: '图像手记(Visual Journal),面向中文创作者的 AI 图像创作工作台。',
9
  icons: {
10
  icon: '/favicon.svg'
11
  }
 
4
  import type { Metadata } from 'next';
5
 
6
  export const metadata: Metadata = {
7
+ title: '图像手记 | AI 图像创作工作台',
8
+ description: '图像手记是用于生成、编辑和管理图像的 AI 图像创作工作台。',
9
  icons: {
10
  icon: '/favicon.svg'
11
  }
src/app/page-regressions.test.ts CHANGED
@@ -3,6 +3,35 @@ import { readFile } from 'node:fs/promises';
3
  import { describe, it } from 'node:test';
4
 
5
  describe('page state regressions', () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  it('passes the unified random inspiration picker and batch prompt setter into the generation form', async () => {
7
  const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8');
8
  const generationFormBlock = source.match(/<GenerationForm([\s\S]*?)failedBatchPrompts=/)?.[1] ?? '';
@@ -115,6 +144,21 @@ describe('page state regressions', () => {
115
  assert.doesNotMatch(source, /const activeRequestModeLabel = React\.useMemo/);
116
  });
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  it('keeps mobile actions fixed normally and scrollable in short creation drawers', async () => {
119
  const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8');
120
  const generationFormSource = await readFile(
 
3
  import { describe, it } from 'node:test';
4
 
5
  describe('page state regressions', () => {
6
+ it('keeps the locale selector available before and after entry authentication', async () => {
7
+ const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8');
8
+
9
+ assert.match(source, /import \{ LanguageSelector \} from '@\/components\/language-selector';/);
10
+ assert.equal((source.match(/<LanguageSelector \/>/g) ?? []).length, 2);
11
+ assert.match(
12
+ source,
13
+ /\{showEntryLock \? \(\s*<div[^>]*>\s*<div className='absolute top-3 right-4'>\s*<LanguageSelector \/>/
14
+ );
15
+ assert.match(source, /<WorkbenchStatusStrip[\s\S]*?<LanguageSelector \/>/);
16
+ });
17
+
18
+ it('keeps auxiliary image actions localized instead of rendering raw API text', async () => {
19
+ const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8');
20
+
21
+ assert.doesNotMatch(source, /readApiErrorMessage/);
22
+ assert.doesNotMatch(source, /statusText: response\.statusText/);
23
+ assert.match(source, /t\('error\.fetchImage', \{ status: response\.status \}\)/);
24
+ assert.match(source, /response\.status === 401 \? t\('error\.unauthorized'\)/);
25
+ assert.match(source, /createErrorNotice\(t\('error\.clearHistory'\)\)/);
26
+ assert.match(source, /function getLocalizedImageRequestError/);
27
+ assert.match(source, /t\('error\.invalidRequest'\)/);
28
+ assert.match(source, /t\('error\.networkRequest'\)/);
29
+ assert.match(source, /new ApiRequestError\(t\('error\.streaming'\)\)/);
30
+ assert.match(source, /if \(error instanceof BatchPausedError\)/);
31
+ assert.doesNotMatch(source, /new ApiRequestError\(event\.error/);
32
+ assert.doesNotMatch(source, /result\.error \|\|/);
33
+ });
34
+
35
  it('passes the unified random inspiration picker and batch prompt setter into the generation form', async () => {
36
  const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8');
37
  const generationFormBlock = source.match(/<GenerationForm([\s\S]*?)failedBatchPrompts=/)?.[1] ?? '';
 
144
  assert.doesNotMatch(source, /const activeRequestModeLabel = React\.useMemo/);
145
  });
146
 
147
+ it('labels the selected output count as requested images instead of tasks', async () => {
148
+ const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8');
149
+
150
+ assert.match(
151
+ source,
152
+ /const activeRequestedImageCount =\s*mode === 'generate' && workbenchMode === 'batch'\s*\? readBatchPromptLines\(genBatchPromptText\)\.length\s*:\s*mode === 'generate'\s*\? genN\[0\]\s*:\s*editN\[0\];/
153
+ );
154
+ assert.match(
155
+ source,
156
+ /const activeRequestSummaryLabel = t\('workbench\.requestSummary', \{\s*count: activeRequestedImageCount\s*\}\);/
157
+ );
158
+ assert.match(source, /requestSummaryLabel=\{activeRequestSummaryLabel\}/);
159
+ assert.doesNotMatch(source, /workbench\.taskSummary/);
160
+ });
161
+
162
  it('keeps mobile actions fixed normally and scrollable in short creation drawers', async () => {
163
  const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8');
164
  const generationFormSource = await readFile(
src/app/page.tsx CHANGED
@@ -5,6 +5,7 @@ import { EditingForm, type EditingFormData, type EditingReuseContext } from '@/c
5
  import { GenerationForm, type GenerationFormData, type WorkbenchReuseContext } from '@/components/generation-form';
6
  import { HistoryPanel, type InspirationItem, type PromptApplySource } from '@/components/history-panel';
7
  import { ImageOutput } from '@/components/image-output';
 
8
  import type { WorkbenchMode } from '@/components/mode-toggle';
9
  import { PasswordDialog } from '@/components/password-dialog';
10
  import { ShareDialog, type ShareDialogValues } from '@/components/share-dialog';
@@ -113,12 +114,10 @@ import type { ActualCostDetails } from '@/lib/upstream-cost/resolve';
113
  import { cn } from '@/lib/utils';
114
  import {
115
  mergeWebuiImageRetentionResults,
116
- readApiErrorMessage,
117
  readWebuiImageFileOperationResults,
118
  readWebuiImageRetentionFilenames,
119
  type WebuiImageRetentionAction
120
  } from '@/lib/webui-image-retention-client';
121
- import { formatEstimatedCredits } from '@/lib/workbench-cost-label';
122
  import { createZipBlob } from '@/lib/zip-export';
123
  import { useLiveQuery } from 'dexie-react-hooks';
124
  import { Activity, ArrowUp, Loader2, Lock, Pause, PenLine, Settings2, X } from 'lucide-react';
@@ -435,12 +434,22 @@ class ApiRequestError extends Error {
435
  }
436
  }
437
 
438
- function summarizeApiError(error: unknown, fallbackMessage: string): { message: string; status?: number } {
 
 
 
 
 
 
 
 
 
 
439
  if (error instanceof ApiRequestError) {
440
  return { message: error.message, status: error.status };
441
  }
442
- if (error instanceof Error) {
443
- return { message: error.message };
444
  }
445
  return { message: fallbackMessage };
446
  }
@@ -759,19 +768,19 @@ export default function HomePage() {
759
  streamingStrategy: activeEffectiveStreamingStrategy,
760
  streamMode
761
  });
762
- const activeTaskCount =
763
  mode === 'generate' && workbenchMode === 'batch'
764
  ? readBatchPromptLines(genBatchPromptText).length
765
  : mode === 'generate'
766
  ? genN[0]
767
  : editN[0];
768
- const activeEstimatedCostLabel = t('workbench.estimatedCost', {
769
- credits: formatEstimatedCredits(activeTaskCount)
770
  });
771
  const activeParallelBatchVisible = resolveStreamingBatchToggleState({
772
  allowStreamingBatch: streamingBatchEnabled,
773
  userEnabled: enableParallelBatch,
774
- targetCount: activeTaskCount,
775
  streamMode,
776
  streamingStrategy: activeEffectiveStreamingStrategy
777
  }).checked;
@@ -1387,11 +1396,25 @@ export default function HomePage() {
1387
  const loadPermanentlySavedFilenames = async () => {
1388
  try {
1389
  const response = await fetch('/api/image-retention', { signal: controller.signal });
1390
- const body: unknown = await response.json();
1391
  if (!response.ok) {
1392
- throw new Error(readApiErrorMessage(body) ?? t('retention.loadFailed'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1393
  }
1394
- const filenames = readWebuiImageRetentionFilenames(body);
1395
  if (!controller.signal.aborted) {
1396
  setLoadedWebuiImageRetentionState({
1397
  scopeKey: retentionScopeKey,
@@ -1400,7 +1423,7 @@ export default function HomePage() {
1400
  }
1401
  } catch (error) {
1402
  if (controller.signal.aborted) return;
1403
- console.error('读取永久存图片状态失败:', error);
1404
  setError((current) => current ?? createErrorNotice(t('retention.loadFailed')));
1405
  }
1406
  };
@@ -1415,15 +1438,20 @@ export default function HomePage() {
1415
  throw new Error(t('retention.updateFailed'));
1416
  }
1417
 
1418
- const response = await fetch('/api/image-retention', {
1419
- method: 'POST',
1420
- headers: { 'Content-Type': 'application/json' },
1421
- body: JSON.stringify({
1422
- action,
1423
- filenames,
1424
- ...(clientPasswordHash ? { passwordHash: clientPasswordHash } : {})
1425
- })
1426
- });
 
 
 
 
 
1427
  let body: unknown;
1428
  try {
1429
  body = await response.json();
@@ -1431,10 +1459,15 @@ export default function HomePage() {
1431
  throw new Error(t('retention.updateFailed'));
1432
  }
1433
  if (!response.ok && response.status !== 207) {
1434
- throw new Error(readApiErrorMessage(body) ?? t('retention.updateFailed'));
1435
  }
1436
 
1437
- const results = readWebuiImageFileOperationResults(body);
 
 
 
 
 
1438
  setLoadedWebuiImageRetentionState((current) =>
1439
  current?.scopeKey === retentionScopeKey
1440
  ? {
@@ -1588,7 +1621,7 @@ export default function HomePage() {
1588
  const indexedDbImages = await Promise.all(
1589
  images.map(async (img) => {
1590
  if (!img.b64_json) {
1591
- throw new Error(t('error.imageMissingBase64', { filename: img.filename }));
1592
  }
1593
  const byteCharacters = atob(img.b64_json);
1594
  const byteNumbers = new Array(byteCharacters.length);
@@ -1624,7 +1657,7 @@ export default function HomePage() {
1624
  ...(img.clientRequestId ? { clientRequestId: img.clientRequestId } : {})
1625
  }));
1626
  if (fsImages.length !== images.length) {
1627
- throw new Error(t('error.apiOmittedPaths'));
1628
  }
1629
  return fsImages;
1630
  },
@@ -1643,7 +1676,7 @@ export default function HomePage() {
1643
  promptOverride?: string
1644
  ) => {
1645
  if (images.length === 0) {
1646
- throw new Error(t('error.noImages'));
1647
  }
1648
 
1649
  const processedImages = await materializeImages(images);
@@ -1689,7 +1722,7 @@ export default function HomePage() {
1689
  if (isPasswordRequiredByBackend && effectivePasswordHash) {
1690
  apiFormData.append('passwordHash', effectivePasswordHash);
1691
  } else if (isPasswordRequiredByBackend && !effectivePasswordHash) {
1692
- throw new Error(t('error.passwordRequired'));
1693
  }
1694
  apiFormData.append('mode', requestMode);
1695
  if (apiSettings.apiKey) {
@@ -1787,15 +1820,20 @@ export default function HomePage() {
1787
  } = {}
1788
  ): Promise<{ images: ApiImageResponseItem[]; usage: unknown; actualCost?: ActualCostDetails }> => {
1789
  const formClientRequestId = String(apiFormData.get('clientRequestId') || '');
1790
- const response = await fetch('/api/images', {
1791
- method: 'POST',
1792
- body: apiFormData
1793
- });
 
 
 
 
 
1794
 
1795
  const contentType = response.headers.get('content-type');
1796
  if (contentType?.includes('text/event-stream')) {
1797
  if (!response.body) {
1798
- throw new Error(t('error.responseBodyNull'));
1799
  }
1800
 
1801
  const reader = response.body.getReader();
@@ -1812,7 +1850,12 @@ export default function HomePage() {
1812
  .map((line) => line.slice(6));
1813
  if (dataLines.length === 0) return;
1814
 
1815
- const event = JSON.parse(dataLines.join('\n'));
 
 
 
 
 
1816
  if (event.type === 'partial_image') {
1817
  const imageIndex = options.previewIndexOffset ?? event.index ?? 0;
1818
  const dataUrl = `data:image/png;base64,${event.b64_json}`;
@@ -1822,14 +1865,24 @@ export default function HomePage() {
1822
  return newMap;
1823
  });
1824
  } else if (event.type === 'error') {
1825
- throw new ApiRequestError(event.error || t('error.streaming'), event.status);
1826
  } else if (event.type === 'completed' || event.type === 'done') {
1827
- streamingState = applyStreamingClientEvent(streamingState, event as StreamingClientEvent);
 
 
 
 
1828
  }
1829
  };
1830
 
1831
  while (true) {
1832
- const { done, value } = await reader.read();
 
 
 
 
 
 
1833
  if (done) break;
1834
 
1835
  buffer += decoder.decode(value, { stream: true });
@@ -1869,9 +1922,9 @@ export default function HomePage() {
1869
  result = await response.json();
1870
  } catch (error) {
1871
  if (!response.ok) {
1872
- throw new ApiRequestError(t('error.apiFailed', { status: response.status }), response.status);
1873
  }
1874
- throw error;
1875
  }
1876
 
1877
  if (!response.ok) {
@@ -1898,10 +1951,7 @@ export default function HomePage() {
1898
  promptForExpiredPassword();
1899
  throw new ApiRequestError(t('error.passwordExpired'), 401, { preserveDisplayedError: true });
1900
  }
1901
- throw new ApiRequestError(
1902
- result.error || t('error.apiFailed', { status: response.status }),
1903
- response.status
1904
- );
1905
  }
1906
 
1907
  return {
@@ -2136,7 +2186,7 @@ export default function HomePage() {
2136
  }
2137
  if (successes.length === 0) {
2138
  setFailedBatchPrompts(failedPromptBatch);
2139
- throw errors[0] || new Error(t('error.noImages'));
2140
  }
2141
  const images = successes.flatMap((result) => result.images);
2142
  const usage = mergeUsageValues(successes.map((result) => result.usage));
@@ -2163,7 +2213,7 @@ export default function HomePage() {
2163
  buildBatchPartialFailureMessage({
2164
  failed: errors.length,
2165
  total: jobs.length,
2166
- errors: errors.map((error) => summarizeApiError(error, t('error.unexpected'))),
2167
  t
2168
  })
2169
  )
@@ -2196,7 +2246,7 @@ export default function HomePage() {
2196
  return;
2197
  }
2198
  if (successes.length === 0) {
2199
- throw errors[0] || new Error(t('error.noImages'));
2200
  }
2201
  const images = successes.flatMap((result) => result.images);
2202
  const usage = mergeUsageValues(successes.map((result) => result.usage));
@@ -2219,7 +2269,7 @@ export default function HomePage() {
2219
  buildBatchPartialFailureMessage({
2220
  failed: errors.length,
2221
  total: jobs.length,
2222
- errors: errors.map((error) => summarizeApiError(error, t('error.unexpected'))),
2223
  t
2224
  })
2225
  )
@@ -2250,7 +2300,7 @@ export default function HomePage() {
2250
  setStreamingPreviewImages(new Map());
2251
  return;
2252
  }
2253
- const errorSummary = summarizeApiError(err, t('error.unexpected'));
2254
  const message = buildUserFacingApiErrorMessage({ ...errorSummary, t });
2255
  setError(createErrorNotice(message));
2256
  setGenerationFailureMessage(message);
@@ -3136,9 +3186,7 @@ export default function HomePage() {
3136
  scheduleResultFeedbackDeleteTargets(feedbackDeleteTargets);
3137
  } catch (e) {
3138
  console.error('清空历史记录失败:', e);
3139
- setError(
3140
- createErrorNotice(t('error.clearHistory', { message: e instanceof Error ? e.message : String(e) }))
3141
- );
3142
  }
3143
  }
3144
  }, [createErrorNotice, history, scheduleResultFeedbackDeleteTargets, t]);
@@ -3148,19 +3196,28 @@ export default function HomePage() {
3148
  if (storageMode === 'indexeddb') {
3149
  const record = allDbImages?.find((img) => img.filename === filename);
3150
  if (!record?.blob) {
3151
- throw new Error(t('error.imageNotFoundDb', { filename }));
3152
  }
3153
  return record.blob;
3154
  }
3155
 
3156
  if (!(await refreshImageAccessCookie())) {
3157
- throw new Error(t('error.imageAccessRefreshFailed'));
 
 
 
 
 
 
3158
  }
3159
- const response = await fetch(`/api/image/${filename}`);
3160
  if (!response.ok) {
3161
- throw new Error(t('error.fetchImage', { statusText: response.statusText }));
 
 
 
 
 
3162
  }
3163
- return response.blob();
3164
  },
3165
  [allDbImages, refreshImageAccessCookie, t]
3166
  );
@@ -3179,7 +3236,9 @@ export default function HomePage() {
3179
  window.setTimeout(() => URL.revokeObjectURL(url), 150);
3180
  } catch (error) {
3181
  setError(
3182
- createErrorNotice(error instanceof Error ? error.message : t('error.retrieveImage', { filename }))
 
 
3183
  );
3184
  }
3185
  },
@@ -3208,9 +3267,7 @@ export default function HomePage() {
3208
  window.setTimeout(() => URL.revokeObjectURL(url), 150);
3209
  } catch (error) {
3210
  setError(
3211
- createErrorNotice(
3212
- error instanceof Error ? error.message : t('error.retrieveImage', { filename: 'history.zip' })
3213
- )
3214
  );
3215
  }
3216
  },
@@ -3299,13 +3356,18 @@ export default function HomePage() {
3299
  blob = record.blob;
3300
  mimeType = blob.type || mimeType;
3301
  } else {
3302
- throw new Error(t('error.imageNotFoundDb', { filename }));
3303
  }
3304
  } else {
3305
  if (!(await refreshImageAccessCookie())) {
3306
  return false;
3307
  }
3308
- const response = await fetch(`/api/image/${filename}`);
 
 
 
 
 
3309
  if (response.status === 401 && isPasswordRequiredByBackend) {
3310
  let result: { code?: string } = {};
3311
  try {
@@ -3321,14 +3383,18 @@ export default function HomePage() {
3321
  return false;
3322
  }
3323
  if (!response.ok) {
3324
- throw new Error(t('error.fetchImage', { statusText: response.statusText }));
 
 
 
 
 
3325
  }
3326
- blob = await response.blob();
3327
  mimeType = response.headers.get('Content-Type') || mimeType;
3328
  }
3329
 
3330
  if (!blob) {
3331
- throw new Error(t('error.retrieveImage', { filename }));
3332
  }
3333
 
3334
  const newFile = new File([blob], filename, { type: mimeType });
@@ -3345,7 +3411,7 @@ export default function HomePage() {
3345
  return true;
3346
  } catch (err: unknown) {
3347
  console.error('发送图片到编辑模式失败:', err);
3348
- const errorMessage = err instanceof Error ? err.message : t('error.sendToEdit');
3349
  setError(createErrorNotice(errorMessage));
3350
  return false;
3351
  } finally {
@@ -3449,19 +3515,34 @@ export default function HomePage() {
3449
  apiPayload.passwordHash = clientPasswordHash;
3450
  }
3451
 
3452
- const response = await fetch('/api/image-delete', {
3453
- method: 'POST',
3454
- headers: { 'Content-Type': 'application/json' },
3455
- body: JSON.stringify(apiPayload)
3456
- });
 
 
 
 
 
3457
 
3458
- const result: unknown = await response.json();
 
 
 
 
 
3459
  if (!response.ok) {
3460
- throw new Error(
3461
- readApiErrorMessage(result) ?? `API deletion failed with status ${response.status}`
3462
  );
3463
  }
3464
- const deletionResults = readWebuiImageFileOperationResults(result);
 
 
 
 
 
3465
  setLoadedWebuiImageRetentionState((current) =>
3466
  current?.scopeKey === retentionScopeKey
3467
  ? {
@@ -3525,7 +3606,7 @@ export default function HomePage() {
3525
  scheduleResultFeedbackDeleteTargets(buildHistoryFeedbackDeleteTargets([item]));
3526
  } catch (e: unknown) {
3527
  console.error('删除条目失败:', e);
3528
- setError(createErrorNotice(e instanceof Error ? e.message : t('error.deleteUnexpected')));
3529
  } finally {
3530
  setItemToDeleteConfirm(null);
3531
  }
@@ -3637,6 +3718,9 @@ export default function HomePage() {
3637
  ) : null}
3638
  {showEntryLock ? (
3639
  <div className='mx-auto flex min-h-screen w-full max-w-md flex-col items-center justify-center gap-6 px-4 text-center'>
 
 
 
3640
  <div className='bg-primary text-primary-foreground border-primary/20 flex size-14 items-center justify-center rounded-full border shadow-sm'>
3641
  <Lock className='h-6 w-6' />
3642
  </div>
@@ -3707,11 +3791,12 @@ export default function HomePage() {
3707
  routeLabel={activeRouteLabel}
3708
  streamStatus={getStreamingStatusLabel(streamMode, t)}
3709
  parallelBatchEnabled={activeParallelBatchVisible}
3710
- costLabel={activeEstimatedCostLabel}
3711
  runtimeHealthStatus={activeRuntimeHealthStatus}
3712
  channelRouting={runtimeCapabilities?.channelRouting ?? null}
3713
  runtimeLastFailure={runtimeCapabilities?.streamingBatch.lastFailure ?? null}
3714
  />
 
3715
  <div className='text-muted-foreground hidden items-center sm:flex'>
3716
  <button
3717
  type='button'
@@ -3840,7 +3925,7 @@ export default function HomePage() {
3840
  setPromptOptimization={setGenPromptOptimization}
3841
  forceWeb={genForceWeb}
3842
  setForceWeb={setGenForceWeb}
3843
- estimatedCostLabel={activeEstimatedCostLabel}
3844
  />
3845
  </div>
3846
  <div
@@ -3935,7 +4020,7 @@ export default function HomePage() {
3935
  setEditPromptOptimization={setEditPromptOptimization}
3936
  editForceWeb={editForceWeb}
3937
  setEditForceWeb={setEditForceWeb}
3938
- estimatedCostLabel={activeEstimatedCostLabel}
3939
  />
3940
  </div>
3941
  </section>
 
5
  import { GenerationForm, type GenerationFormData, type WorkbenchReuseContext } from '@/components/generation-form';
6
  import { HistoryPanel, type InspirationItem, type PromptApplySource } from '@/components/history-panel';
7
  import { ImageOutput } from '@/components/image-output';
8
+ import { LanguageSelector } from '@/components/language-selector';
9
  import type { WorkbenchMode } from '@/components/mode-toggle';
10
  import { PasswordDialog } from '@/components/password-dialog';
11
  import { ShareDialog, type ShareDialogValues } from '@/components/share-dialog';
 
114
  import { cn } from '@/lib/utils';
115
  import {
116
  mergeWebuiImageRetentionResults,
 
117
  readWebuiImageFileOperationResults,
118
  readWebuiImageRetentionFilenames,
119
  type WebuiImageRetentionAction
120
  } from '@/lib/webui-image-retention-client';
 
121
  import { createZipBlob } from '@/lib/zip-export';
122
  import { useLiveQuery } from 'dexie-react-hooks';
123
  import { Activity, ArrowUp, Loader2, Lock, Pause, PenLine, Settings2, X } from 'lucide-react';
 
434
  }
435
  }
436
 
437
+ function getLocalizedImageRequestError(t: ReturnType<typeof useI18n>['t'], status: number | undefined): string {
438
+ if (status === 400) return t('error.invalidRequest');
439
+ if (status === undefined) return t('error.streaming');
440
+ return t('error.apiFailed', { status });
441
+ }
442
+
443
+ function summarizeApiError(
444
+ error: unknown,
445
+ fallbackMessage: string,
446
+ t: ReturnType<typeof useI18n>['t']
447
+ ): { message: string; status?: number } {
448
  if (error instanceof ApiRequestError) {
449
  return { message: error.message, status: error.status };
450
  }
451
+ if (error instanceof BatchPausedError) {
452
+ return { message: t('batch.pausedFailure') };
453
  }
454
  return { message: fallbackMessage };
455
  }
 
768
  streamingStrategy: activeEffectiveStreamingStrategy,
769
  streamMode
770
  });
771
+ const activeRequestedImageCount =
772
  mode === 'generate' && workbenchMode === 'batch'
773
  ? readBatchPromptLines(genBatchPromptText).length
774
  : mode === 'generate'
775
  ? genN[0]
776
  : editN[0];
777
+ const activeRequestSummaryLabel = t('workbench.requestSummary', {
778
+ count: activeRequestedImageCount
779
  });
780
  const activeParallelBatchVisible = resolveStreamingBatchToggleState({
781
  allowStreamingBatch: streamingBatchEnabled,
782
  userEnabled: enableParallelBatch,
783
+ targetCount: activeRequestedImageCount,
784
  streamMode,
785
  streamingStrategy: activeEffectiveStreamingStrategy
786
  }).checked;
 
1396
  const loadPermanentlySavedFilenames = async () => {
1397
  try {
1398
  const response = await fetch('/api/image-retention', { signal: controller.signal });
1399
+ const body: unknown = await response.json().catch(() => null);
1400
  if (!response.ok) {
1401
+ if (!controller.signal.aborted) {
1402
+ setError(
1403
+ (current) =>
1404
+ current ??
1405
+ createErrorNotice(
1406
+ response.status === 401 ? t('error.unauthorized') : t('retention.loadFailed')
1407
+ )
1408
+ );
1409
+ }
1410
+ return;
1411
+ }
1412
+ let filenames: ReturnType<typeof readWebuiImageRetentionFilenames>;
1413
+ try {
1414
+ filenames = readWebuiImageRetentionFilenames(body);
1415
+ } catch {
1416
+ throw new Error(t('retention.loadFailed'));
1417
  }
 
1418
  if (!controller.signal.aborted) {
1419
  setLoadedWebuiImageRetentionState({
1420
  scopeKey: retentionScopeKey,
 
1423
  }
1424
  } catch (error) {
1425
  if (controller.signal.aborted) return;
1426
+ console.error('读取自动清理状态失败:', error);
1427
  setError((current) => current ?? createErrorNotice(t('retention.loadFailed')));
1428
  }
1429
  };
 
1438
  throw new Error(t('retention.updateFailed'));
1439
  }
1440
 
1441
+ let response: Response;
1442
+ try {
1443
+ response = await fetch('/api/image-retention', {
1444
+ method: 'POST',
1445
+ headers: { 'Content-Type': 'application/json' },
1446
+ body: JSON.stringify({
1447
+ action,
1448
+ filenames,
1449
+ ...(clientPasswordHash ? { passwordHash: clientPasswordHash } : {})
1450
+ })
1451
+ });
1452
+ } catch {
1453
+ throw new Error(t('retention.updateFailed'));
1454
+ }
1455
  let body: unknown;
1456
  try {
1457
  body = await response.json();
 
1459
  throw new Error(t('retention.updateFailed'));
1460
  }
1461
  if (!response.ok && response.status !== 207) {
1462
+ throw new Error(response.status === 401 ? t('error.unauthorized') : t('retention.updateFailed'));
1463
  }
1464
 
1465
+ let results: ReturnType<typeof readWebuiImageFileOperationResults>;
1466
+ try {
1467
+ results = readWebuiImageFileOperationResults(body);
1468
+ } catch {
1469
+ throw new Error(t('retention.updateFailed'));
1470
+ }
1471
  setLoadedWebuiImageRetentionState((current) =>
1472
  current?.scopeKey === retentionScopeKey
1473
  ? {
 
1621
  const indexedDbImages = await Promise.all(
1622
  images.map(async (img) => {
1623
  if (!img.b64_json) {
1624
+ throw new ApiRequestError(t('error.imageMissingBase64', { filename: img.filename }));
1625
  }
1626
  const byteCharacters = atob(img.b64_json);
1627
  const byteNumbers = new Array(byteCharacters.length);
 
1657
  ...(img.clientRequestId ? { clientRequestId: img.clientRequestId } : {})
1658
  }));
1659
  if (fsImages.length !== images.length) {
1660
+ throw new ApiRequestError(t('error.apiOmittedPaths'));
1661
  }
1662
  return fsImages;
1663
  },
 
1676
  promptOverride?: string
1677
  ) => {
1678
  if (images.length === 0) {
1679
+ throw new ApiRequestError(t('error.noImages'));
1680
  }
1681
 
1682
  const processedImages = await materializeImages(images);
 
1722
  if (isPasswordRequiredByBackend && effectivePasswordHash) {
1723
  apiFormData.append('passwordHash', effectivePasswordHash);
1724
  } else if (isPasswordRequiredByBackend && !effectivePasswordHash) {
1725
+ throw new ApiRequestError(t('error.passwordRequired'));
1726
  }
1727
  apiFormData.append('mode', requestMode);
1728
  if (apiSettings.apiKey) {
 
1820
  } = {}
1821
  ): Promise<{ images: ApiImageResponseItem[]; usage: unknown; actualCost?: ActualCostDetails }> => {
1822
  const formClientRequestId = String(apiFormData.get('clientRequestId') || '');
1823
+ let response: Response;
1824
+ try {
1825
+ response = await fetch('/api/images', {
1826
+ method: 'POST',
1827
+ body: apiFormData
1828
+ });
1829
+ } catch {
1830
+ throw new ApiRequestError(t('error.networkRequest'));
1831
+ }
1832
 
1833
  const contentType = response.headers.get('content-type');
1834
  if (contentType?.includes('text/event-stream')) {
1835
  if (!response.body) {
1836
+ throw new ApiRequestError(t('error.responseBodyNull'));
1837
  }
1838
 
1839
  const reader = response.body.getReader();
 
1850
  .map((line) => line.slice(6));
1851
  if (dataLines.length === 0) return;
1852
 
1853
+ let event: StreamingClientEvent;
1854
+ try {
1855
+ event = JSON.parse(dataLines.join('\n')) as StreamingClientEvent;
1856
+ } catch {
1857
+ throw new ApiRequestError(t('error.streaming'));
1858
+ }
1859
  if (event.type === 'partial_image') {
1860
  const imageIndex = options.previewIndexOffset ?? event.index ?? 0;
1861
  const dataUrl = `data:image/png;base64,${event.b64_json}`;
 
1865
  return newMap;
1866
  });
1867
  } else if (event.type === 'error') {
1868
+ throw new ApiRequestError(getLocalizedImageRequestError(t, event.status), event.status);
1869
  } else if (event.type === 'completed' || event.type === 'done') {
1870
+ try {
1871
+ streamingState = applyStreamingClientEvent(streamingState, event);
1872
+ } catch {
1873
+ throw new ApiRequestError(t('error.streaming'));
1874
+ }
1875
  }
1876
  };
1877
 
1878
  while (true) {
1879
+ let chunk: ReadableStreamReadResult<Uint8Array>;
1880
+ try {
1881
+ chunk = await reader.read();
1882
+ } catch {
1883
+ throw new ApiRequestError(t('error.streaming'));
1884
+ }
1885
+ const { done, value } = chunk;
1886
  if (done) break;
1887
 
1888
  buffer += decoder.decode(value, { stream: true });
 
1922
  result = await response.json();
1923
  } catch (error) {
1924
  if (!response.ok) {
1925
+ throw new ApiRequestError(getLocalizedImageRequestError(t, response.status), response.status);
1926
  }
1927
+ throw new ApiRequestError(t('error.unexpected'), response.status);
1928
  }
1929
 
1930
  if (!response.ok) {
 
1951
  promptForExpiredPassword();
1952
  throw new ApiRequestError(t('error.passwordExpired'), 401, { preserveDisplayedError: true });
1953
  }
1954
+ throw new ApiRequestError(getLocalizedImageRequestError(t, response.status), response.status);
 
 
 
1955
  }
1956
 
1957
  return {
 
2186
  }
2187
  if (successes.length === 0) {
2188
  setFailedBatchPrompts(failedPromptBatch);
2189
+ throw errors[0] || new ApiRequestError(t('error.noImages'));
2190
  }
2191
  const images = successes.flatMap((result) => result.images);
2192
  const usage = mergeUsageValues(successes.map((result) => result.usage));
 
2213
  buildBatchPartialFailureMessage({
2214
  failed: errors.length,
2215
  total: jobs.length,
2216
+ errors: errors.map((error) => summarizeApiError(error, t('error.unexpected'), t)),
2217
  t
2218
  })
2219
  )
 
2246
  return;
2247
  }
2248
  if (successes.length === 0) {
2249
+ throw errors[0] || new ApiRequestError(t('error.noImages'));
2250
  }
2251
  const images = successes.flatMap((result) => result.images);
2252
  const usage = mergeUsageValues(successes.map((result) => result.usage));
 
2269
  buildBatchPartialFailureMessage({
2270
  failed: errors.length,
2271
  total: jobs.length,
2272
+ errors: errors.map((error) => summarizeApiError(error, t('error.unexpected'), t)),
2273
  t
2274
  })
2275
  )
 
2300
  setStreamingPreviewImages(new Map());
2301
  return;
2302
  }
2303
+ const errorSummary = summarizeApiError(err, t('error.unexpected'), t);
2304
  const message = buildUserFacingApiErrorMessage({ ...errorSummary, t });
2305
  setError(createErrorNotice(message));
2306
  setGenerationFailureMessage(message);
 
3186
  scheduleResultFeedbackDeleteTargets(feedbackDeleteTargets);
3187
  } catch (e) {
3188
  console.error('清空历史记录失败:', e);
3189
+ setError(createErrorNotice(t('error.clearHistory')));
 
 
3190
  }
3191
  }
3192
  }, [createErrorNotice, history, scheduleResultFeedbackDeleteTargets, t]);
 
3196
  if (storageMode === 'indexeddb') {
3197
  const record = allDbImages?.find((img) => img.filename === filename);
3198
  if (!record?.blob) {
3199
+ throw new ApiRequestError(t('error.imageNotFoundDb', { filename }));
3200
  }
3201
  return record.blob;
3202
  }
3203
 
3204
  if (!(await refreshImageAccessCookie())) {
3205
+ throw new ApiRequestError(t('error.imageAccessRefreshFailed'));
3206
+ }
3207
+ let response: Response;
3208
+ try {
3209
+ response = await fetch(`/api/image/${filename}`);
3210
+ } catch {
3211
+ throw new ApiRequestError(t('error.retrieveImage', { filename }));
3212
  }
 
3213
  if (!response.ok) {
3214
+ throw new ApiRequestError(t('error.fetchImage', { status: response.status }));
3215
+ }
3216
+ try {
3217
+ return await response.blob();
3218
+ } catch {
3219
+ throw new ApiRequestError(t('error.retrieveImage', { filename }));
3220
  }
 
3221
  },
3222
  [allDbImages, refreshImageAccessCookie, t]
3223
  );
 
3236
  window.setTimeout(() => URL.revokeObjectURL(url), 150);
3237
  } catch (error) {
3238
  setError(
3239
+ createErrorNotice(
3240
+ error instanceof ApiRequestError ? error.message : t('error.retrieveImage', { filename })
3241
+ )
3242
  );
3243
  }
3244
  },
 
3267
  window.setTimeout(() => URL.revokeObjectURL(url), 150);
3268
  } catch (error) {
3269
  setError(
3270
+ createErrorNotice(error instanceof ApiRequestError ? error.message : t('error.downloadHistory'))
 
 
3271
  );
3272
  }
3273
  },
 
3356
  blob = record.blob;
3357
  mimeType = blob.type || mimeType;
3358
  } else {
3359
+ throw new ApiRequestError(t('error.imageNotFoundDb', { filename }));
3360
  }
3361
  } else {
3362
  if (!(await refreshImageAccessCookie())) {
3363
  return false;
3364
  }
3365
+ let response: Response;
3366
+ try {
3367
+ response = await fetch(`/api/image/${filename}`);
3368
+ } catch {
3369
+ throw new ApiRequestError(t('error.sendToEdit'));
3370
+ }
3371
  if (response.status === 401 && isPasswordRequiredByBackend) {
3372
  let result: { code?: string } = {};
3373
  try {
 
3383
  return false;
3384
  }
3385
  if (!response.ok) {
3386
+ throw new ApiRequestError(t('error.fetchImage', { status: response.status }));
3387
+ }
3388
+ try {
3389
+ blob = await response.blob();
3390
+ } catch {
3391
+ throw new ApiRequestError(t('error.sendToEdit'));
3392
  }
 
3393
  mimeType = response.headers.get('Content-Type') || mimeType;
3394
  }
3395
 
3396
  if (!blob) {
3397
+ throw new ApiRequestError(t('error.retrieveImage', { filename }));
3398
  }
3399
 
3400
  const newFile = new File([blob], filename, { type: mimeType });
 
3411
  return true;
3412
  } catch (err: unknown) {
3413
  console.error('发送图片到编辑模式失败:', err);
3414
+ const errorMessage = err instanceof ApiRequestError ? err.message : t('error.sendToEdit');
3415
  setError(createErrorNotice(errorMessage));
3416
  return false;
3417
  } finally {
 
3515
  apiPayload.passwordHash = clientPasswordHash;
3516
  }
3517
 
3518
+ let response: Response;
3519
+ try {
3520
+ response = await fetch('/api/image-delete', {
3521
+ method: 'POST',
3522
+ headers: { 'Content-Type': 'application/json' },
3523
+ body: JSON.stringify(apiPayload)
3524
+ });
3525
+ } catch {
3526
+ throw new ApiRequestError(t('error.deleteUnexpected'));
3527
+ }
3528
 
3529
+ let result: unknown;
3530
+ try {
3531
+ result = await response.json();
3532
+ } catch {
3533
+ throw new ApiRequestError(t('error.deleteUnexpected'));
3534
+ }
3535
  if (!response.ok) {
3536
+ throw new ApiRequestError(
3537
+ response.status === 401 ? t('error.unauthorized') : t('error.deleteUnexpected')
3538
  );
3539
  }
3540
+ let deletionResults: ReturnType<typeof readWebuiImageFileOperationResults>;
3541
+ try {
3542
+ deletionResults = readWebuiImageFileOperationResults(result);
3543
+ } catch {
3544
+ throw new ApiRequestError(t('error.deleteUnexpected'));
3545
+ }
3546
  setLoadedWebuiImageRetentionState((current) =>
3547
  current?.scopeKey === retentionScopeKey
3548
  ? {
 
3606
  scheduleResultFeedbackDeleteTargets(buildHistoryFeedbackDeleteTargets([item]));
3607
  } catch (e: unknown) {
3608
  console.error('删除条目失败:', e);
3609
+ setError(createErrorNotice(e instanceof ApiRequestError ? e.message : t('error.deleteUnexpected')));
3610
  } finally {
3611
  setItemToDeleteConfirm(null);
3612
  }
 
3718
  ) : null}
3719
  {showEntryLock ? (
3720
  <div className='mx-auto flex min-h-screen w-full max-w-md flex-col items-center justify-center gap-6 px-4 text-center'>
3721
+ <div className='absolute top-3 right-4'>
3722
+ <LanguageSelector />
3723
+ </div>
3724
  <div className='bg-primary text-primary-foreground border-primary/20 flex size-14 items-center justify-center rounded-full border shadow-sm'>
3725
  <Lock className='h-6 w-6' />
3726
  </div>
 
3791
  routeLabel={activeRouteLabel}
3792
  streamStatus={getStreamingStatusLabel(streamMode, t)}
3793
  parallelBatchEnabled={activeParallelBatchVisible}
3794
+ requestSummaryLabel={activeRequestSummaryLabel}
3795
  runtimeHealthStatus={activeRuntimeHealthStatus}
3796
  channelRouting={runtimeCapabilities?.channelRouting ?? null}
3797
  runtimeLastFailure={runtimeCapabilities?.streamingBatch.lastFailure ?? null}
3798
  />
3799
+ <LanguageSelector />
3800
  <div className='text-muted-foreground hidden items-center sm:flex'>
3801
  <button
3802
  type='button'
 
3925
  setPromptOptimization={setGenPromptOptimization}
3926
  forceWeb={genForceWeb}
3927
  setForceWeb={setGenForceWeb}
3928
+ requestSummaryLabel={activeRequestSummaryLabel}
3929
  />
3930
  </div>
3931
  <div
 
4020
  setEditPromptOptimization={setEditPromptOptimization}
4021
  editForceWeb={editForceWeb}
4022
  setEditForceWeb={setEditForceWeb}
4023
+ requestSummaryLabel={activeRequestSummaryLabel}
4024
  />
4025
  </div>
4026
  </section>
src/app/share/[token]/page-regressions.test.ts CHANGED
@@ -16,4 +16,14 @@ describe('share page regressions', () => {
16
  assert.match(source, /id='share-page-access-code-hint'/);
17
  assert.match(source, /if \(accessCode\.trim\(\)\.length === 0\) \{\s*return;\s*\}/);
18
  });
 
 
 
 
 
 
 
 
 
 
19
  });
 
16
  assert.match(source, /id='share-page-access-code-hint'/);
17
  assert.match(source, /if \(accessCode\.trim\(\)\.length === 0\) \{\s*return;\s*\}/);
18
  });
19
+
20
+ it('maps share API error codes to localized messages instead of rendering API error text', async () => {
21
+ const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8');
22
+
23
+ assert.match(source, /resolveShareApiErrorMessage/);
24
+ assert.match(source, /resolveShareApiErrorMessage\(body, t, 'share\.loadFailed'\)/);
25
+ assert.match(source, /resolveShareApiErrorMessage\(imageBody, t, 'share\.unlockFailed'\)/);
26
+ assert.doesNotMatch(source, /body\.error/);
27
+ assert.doesNotMatch(source, /imageBody\.error/);
28
+ });
29
  });
src/app/share/[token]/page.tsx CHANGED
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
4
  import { Input } from '@/components/ui/input';
5
  import { Label } from '@/components/ui/label';
6
  import { useI18n } from '@/lib/i18n';
 
7
  import { buildShareApiPath } from '@/lib/share-route-paths';
8
  import Image from 'next/image';
9
  import * as React from 'react';
@@ -19,6 +20,12 @@ type ShareMetadata = {
19
  expired: boolean;
20
  };
21
 
 
 
 
 
 
 
22
  export default function SharePage({ params }: { params: Promise<{ token: string }> }) {
23
  const { t } = useI18n();
24
  const resolvedParams = React.use(params);
@@ -38,9 +45,9 @@ export default function SharePage({ params }: { params: Promise<{ token: string
38
  setError(null);
39
  try {
40
  const response = await fetch(buildShareApiPath({ pathname: window.location.pathname, token }));
41
- const body = await response.json();
42
  if (!response.ok) {
43
- throw new Error(body.error || t('share.loadFailed'));
44
  }
45
  if (active) {
46
  const nextMetadata = body as ShareMetadata;
@@ -55,11 +62,13 @@ export default function SharePage({ params }: { params: Promise<{ token: string
55
  }
56
  );
57
  if (!imageResponse.ok) {
58
- const imageBody = await imageResponse.json().catch(() => ({}));
59
- throw new Error(imageBody.error || t('share.unlockFailed'));
 
 
60
  }
61
  if (!imageResponse.headers.get('content-type')?.startsWith('image/')) {
62
- throw new Error(t('share.unlockFailed'));
63
  }
64
  const blob = await imageResponse.blob();
65
  const nextUrl = URL.createObjectURL(blob);
@@ -69,7 +78,7 @@ export default function SharePage({ params }: { params: Promise<{ token: string
69
  }
70
  }
71
  } catch (err) {
72
- if (active) setError(err instanceof Error ? err.message : t('share.loadFailed'));
73
  } finally {
74
  if (active) setIsLoading(false);
75
  }
@@ -100,11 +109,11 @@ export default function SharePage({ params }: { params: Promise<{ token: string
100
  }
101
  );
102
  if (!response.ok) {
103
- const body = await response.json().catch(() => ({}));
104
- throw new Error(body.error || t('share.unlockFailed'));
105
  }
106
  if (!response.headers.get('content-type')?.startsWith('image/')) {
107
- throw new Error(t('share.unlockFailed'));
108
  }
109
  const blob = await response.blob();
110
  const nextUrl = URL.createObjectURL(blob);
@@ -112,7 +121,7 @@ export default function SharePage({ params }: { params: Promise<{ token: string
112
  imageUrlRef.current = nextUrl;
113
  setImageUrl(nextUrl);
114
  } catch (err) {
115
- setError(err instanceof Error ? err.message : t('share.unlockFailed'));
116
  } finally {
117
  setIsUnlocking(false);
118
  }
 
4
  import { Input } from '@/components/ui/input';
5
  import { Label } from '@/components/ui/label';
6
  import { useI18n } from '@/lib/i18n';
7
+ import { resolveShareApiErrorMessage } from '@/lib/share-api-error';
8
  import { buildShareApiPath } from '@/lib/share-route-paths';
9
  import Image from 'next/image';
10
  import * as React from 'react';
 
20
  expired: boolean;
21
  };
22
 
23
+ class SharePageUserError extends Error {}
24
+
25
+ function resolveSharePageUserErrorMessage(error: unknown, fallback: string): string {
26
+ return error instanceof SharePageUserError ? error.message : fallback;
27
+ }
28
+
29
  export default function SharePage({ params }: { params: Promise<{ token: string }> }) {
30
  const { t } = useI18n();
31
  const resolvedParams = React.use(params);
 
45
  setError(null);
46
  try {
47
  const response = await fetch(buildShareApiPath({ pathname: window.location.pathname, token }));
48
+ const body: unknown = await response.json().catch(() => null);
49
  if (!response.ok) {
50
+ throw new SharePageUserError(resolveShareApiErrorMessage(body, t, 'share.loadFailed'));
51
  }
52
  if (active) {
53
  const nextMetadata = body as ShareMetadata;
 
62
  }
63
  );
64
  if (!imageResponse.ok) {
65
+ const imageBody: unknown = await imageResponse.json().catch(() => null);
66
+ throw new SharePageUserError(
67
+ resolveShareApiErrorMessage(imageBody, t, 'share.unlockFailed')
68
+ );
69
  }
70
  if (!imageResponse.headers.get('content-type')?.startsWith('image/')) {
71
+ throw new SharePageUserError(t('share.unlockFailed'));
72
  }
73
  const blob = await imageResponse.blob();
74
  const nextUrl = URL.createObjectURL(blob);
 
78
  }
79
  }
80
  } catch (err) {
81
+ if (active) setError(resolveSharePageUserErrorMessage(err, t('share.loadFailed')));
82
  } finally {
83
  if (active) setIsLoading(false);
84
  }
 
109
  }
110
  );
111
  if (!response.ok) {
112
+ const body: unknown = await response.json().catch(() => null);
113
+ throw new SharePageUserError(resolveShareApiErrorMessage(body, t, 'share.unlockFailed'));
114
  }
115
  if (!response.headers.get('content-type')?.startsWith('image/')) {
116
+ throw new SharePageUserError(t('share.unlockFailed'));
117
  }
118
  const blob = await response.blob();
119
  const nextUrl = URL.createObjectURL(blob);
 
121
  imageUrlRef.current = nextUrl;
122
  setImageUrl(nextUrl);
123
  } catch (err) {
124
+ setError(resolveSharePageUserErrorMessage(err, t('share.unlockFailed')));
125
  } finally {
126
  setIsUnlocking(false);
127
  }
src/components/editing-form.test.tsx CHANGED
@@ -177,7 +177,7 @@ function createEditingFormProps({
177
  setEditPromptOptimization: noop,
178
  editForceWeb: false,
179
  setEditForceWeb: noop,
180
- estimatedCostLabel: '预计 0.12 积分',
181
  initialAdvancedOpen: advancedOpen,
182
  initialAdvancedTab: advancedTab
183
  };
@@ -204,8 +204,8 @@ describe('EditingForm submit footer', { concurrency: false }, () => {
204
  assert.match(html, /min-w-0 items-center justify-center rounded-md/);
205
  assert.doesNotMatch(html, /text-center whitespace-nowrap/);
206
  assert.doesNotMatch(html, /min-\[1760px\]:flex/);
207
- assert.match(html, /存为灵感/);
208
- assert.match(html, /随便来点/);
209
  assert.match(html, /min-h-11 min-w-0 items-center justify-center/);
210
  assert.match(html, /focus-visible:ring-2 focus-visible:outline-none/);
211
  assert.match(html, /flex min-h-0 flex-1 flex-col overflow-hidden/);
@@ -231,8 +231,8 @@ describe('EditingForm submit footer', { concurrency: false }, () => {
231
  canApplyRandomInspiration: false
232
  });
233
 
234
- assertButtonDisabled(html, '存为灵感');
235
- assertButtonDisabled(html, '随便来点');
236
  assert.doesNotMatch(html, /用户保存的编辑提示词/);
237
  });
238
 
@@ -257,10 +257,10 @@ describe('EditingForm submit footer', { concurrency: false }, () => {
257
 
258
  try {
259
  const saveButton = [...view.container.querySelectorAll('button')].find((button) =>
260
- button.textContent?.includes('存为灵感')
261
  );
262
  const randomButton = [...view.container.querySelectorAll('button')].find((button) =>
263
- button.textContent?.includes('随便来点')
264
  );
265
 
266
  assert.ok(saveButton, 'missing edit save inspiration button');
@@ -374,7 +374,7 @@ describe('EditingForm advanced upstream controls', () => {
374
  const html = renderEditingForm({ backend: 'server-default', advancedOpen: false });
375
 
376
  assert.match(html, /默认线路/);
377
- assert.match(html, /预计 0\.12 积分/);
378
  });
379
 
380
  it('renders edit model controls only in the professional model tab', () => {
 
177
  setEditPromptOptimization: noop,
178
  editForceWeb: false,
179
  setEditForceWeb: noop,
180
+ requestSummaryLabel: '请求 1 张图片',
181
  initialAdvancedOpen: advancedOpen,
182
  initialAdvancedTab: advancedTab
183
  };
 
204
  assert.match(html, /min-w-0 items-center justify-center rounded-md/);
205
  assert.doesNotMatch(html, /text-center whitespace-nowrap/);
206
  assert.doesNotMatch(html, /min-\[1760px\]:flex/);
207
+ assert.match(html, /收藏提示词/);
208
+ assert.match(html, /随机套用/);
209
  assert.match(html, /min-h-11 min-w-0 items-center justify-center/);
210
  assert.match(html, /focus-visible:ring-2 focus-visible:outline-none/);
211
  assert.match(html, /flex min-h-0 flex-1 flex-col overflow-hidden/);
 
231
  canApplyRandomInspiration: false
232
  });
233
 
234
+ assertButtonDisabled(html, '收藏提示词');
235
+ assertButtonDisabled(html, '随机套用');
236
  assert.doesNotMatch(html, /用户保存的编辑提示词/);
237
  });
238
 
 
257
 
258
  try {
259
  const saveButton = [...view.container.querySelectorAll('button')].find((button) =>
260
+ button.textContent?.includes('收藏提示词')
261
  );
262
  const randomButton = [...view.container.querySelectorAll('button')].find((button) =>
263
+ button.textContent?.includes('随机套用')
264
  );
265
 
266
  assert.ok(saveButton, 'missing edit save inspiration button');
 
374
  const html = renderEditingForm({ backend: 'server-default', advancedOpen: false });
375
 
376
  assert.match(html, /默认线路/);
377
+ assert.match(html, /请求 1 张图片/);
378
  });
379
 
380
  it('renders edit model controls only in the professional model tab', () => {
src/components/editing-form.tsx CHANGED
@@ -15,6 +15,7 @@ import { Textarea } from '@/components/ui/textarea';
15
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
16
  import type { GptImageModel } from '@/lib/cost-utils';
17
  import { useI18n } from '@/lib/i18n';
 
18
  import type {
19
  ImageUpstreamFormBackend,
20
  ImageUpstreamFormPromptOptimization,
@@ -181,7 +182,7 @@ type EditingFormProps = {
181
  setEditPromptOptimization: React.Dispatch<React.SetStateAction<EditingFormData['promptOptimization']>>;
182
  editForceWeb: boolean;
183
  setEditForceWeb: React.Dispatch<React.SetStateAction<boolean>>;
184
- estimatedCostLabel: string;
185
  initialAdvancedOpen?: boolean;
186
  initialAdvancedTab?: AdvancedTab;
187
  };
@@ -294,19 +295,6 @@ function getStreamModeLabel(streamMode: ImageStreamMode, t: (key: string) => str
294
  return t('streaming.modeAuto');
295
  }
296
 
297
- function getQualityLabel(quality: EditingFormData['quality'], t: (key: string) => string): string {
298
- if (quality === 'low') return t('common.low');
299
- if (quality === 'medium') return t('common.medium');
300
- if (quality === 'high') return t('common.high');
301
- return t('common.auto');
302
- }
303
-
304
- function getOutputFormatLabel(format: EditingFormData['output_format'], t: (key: string) => string): string {
305
- if (format === 'jpeg') return t('common.jpeg');
306
- if (format === 'webp') return t('common.webp');
307
- return t('common.png');
308
- }
309
-
310
  export function EditingForm({
311
  onSubmit,
312
  onSaveInspiration,
@@ -385,7 +373,7 @@ export function EditingForm({
385
  setEditPromptOptimization,
386
  editForceWeb,
387
  setEditForceWeb,
388
- estimatedCostLabel,
389
  initialAdvancedOpen = false,
390
  initialAdvancedTab = 'output'
391
  }: EditingFormProps) {
@@ -459,8 +447,8 @@ export function EditingForm({
459
  t
460
  ]);
461
  const advancedSummary = [
462
- `${t('form.quality')}: ${getQualityLabel(editQuality, t)}`,
463
- `${t('form.outputFormat')}: ${getOutputFormatLabel(editOutputFormat, t)}`,
464
  getBackendLabel(editImageBackend, t)
465
  ].join(', ');
466
  const streamModeLabel = getStreamModeLabel(streamMode, t);
@@ -1042,7 +1030,7 @@ export function EditingForm({
1042
  : t('edit.createMask')}
1043
  {editIsMaskSaved && !editShowMaskEditor && (
1044
  <span className='ml-auto text-xs text-emerald-600 dark:text-emerald-400'>
1045
- ({t('common.saved')})
1046
  </span>
1047
  )}
1048
  <ScanEye className='mt-0.5' />
@@ -1240,7 +1228,7 @@ export function EditingForm({
1240
  </div>
1241
  </div>
1242
  <p className='text-muted-foreground ui-stat text-xs'>
1243
- {t('form.pixelsMeta', {
1244
  pixels: editCustomPixels.toLocaleString(locale),
1245
  percent: ((editCustomPixels / 8_294_400) * 100).toFixed(1),
1246
  ratio: editCustomRatio
@@ -1499,12 +1487,24 @@ export function EditingForm({
1499
  <SelectItem value='server-default'>
1500
  {t('upstream.serverDefault')}
1501
  </SelectItem>
1502
- <SelectItem value='none'>none</SelectItem>
1503
- <SelectItem value='minimal'>minimal</SelectItem>
1504
- <SelectItem value='low'>low</SelectItem>
1505
- <SelectItem value='medium'>medium</SelectItem>
1506
- <SelectItem value='high'>high</SelectItem>
1507
- <SelectItem value='xhigh'>xhigh</SelectItem>
 
 
 
 
 
 
 
 
 
 
 
 
1508
  </SelectContent>
1509
  </Select>
1510
  </div>
@@ -1764,21 +1764,21 @@ export function EditingForm({
1764
  <RadioItemWithIcon
1765
  value='png'
1766
  id='edit-format-png'
1767
- label='PNG'
1768
  Icon={FileImage}
1769
  disabled={isLoading}
1770
  />
1771
  <RadioItemWithIcon
1772
  value='jpeg'
1773
  id='edit-format-jpeg'
1774
- label='JPEG'
1775
  Icon={FileImage}
1776
  disabled={isLoading}
1777
  />
1778
  <RadioItemWithIcon
1779
  value='webp'
1780
  id='edit-format-webp'
1781
- label='WebP'
1782
  Icon={FileImage}
1783
  disabled={isLoading}
1784
  />
@@ -1876,7 +1876,7 @@ export function EditingForm({
1876
  </span>
1877
  )}
1878
  <span className='border-primary/20 bg-primary/10 text-primary col-span-2 inline-flex min-w-0 items-center justify-center rounded-md border px-2 py-1 text-center leading-4 break-words whitespace-normal'>
1879
- {estimatedCostLabel}
1880
  </span>
1881
  </div>
1882
  {submitDisabledReason && (
 
15
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
16
  import type { GptImageModel } from '@/lib/cost-utils';
17
  import { useI18n } from '@/lib/i18n';
18
+ import { getImageOutputFormatLabel, getImageQualityLabel } from '@/lib/image-display-labels';
19
  import type {
20
  ImageUpstreamFormBackend,
21
  ImageUpstreamFormPromptOptimization,
 
182
  setEditPromptOptimization: React.Dispatch<React.SetStateAction<EditingFormData['promptOptimization']>>;
183
  editForceWeb: boolean;
184
  setEditForceWeb: React.Dispatch<React.SetStateAction<boolean>>;
185
+ requestSummaryLabel: string;
186
  initialAdvancedOpen?: boolean;
187
  initialAdvancedTab?: AdvancedTab;
188
  };
 
295
  return t('streaming.modeAuto');
296
  }
297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  export function EditingForm({
299
  onSubmit,
300
  onSaveInspiration,
 
373
  setEditPromptOptimization,
374
  editForceWeb,
375
  setEditForceWeb,
376
+ requestSummaryLabel,
377
  initialAdvancedOpen = false,
378
  initialAdvancedTab = 'output'
379
  }: EditingFormProps) {
 
447
  t
448
  ]);
449
  const advancedSummary = [
450
+ `${t('form.quality')}: ${getImageQualityLabel(editQuality, t)}`,
451
+ `${t('form.outputFormat')}: ${getImageOutputFormatLabel(editOutputFormat, t)}`,
452
  getBackendLabel(editImageBackend, t)
453
  ].join(', ');
454
  const streamModeLabel = getStreamModeLabel(streamMode, t);
 
1030
  : t('edit.createMask')}
1031
  {editIsMaskSaved && !editShowMaskEditor && (
1032
  <span className='ml-auto text-xs text-emerald-600 dark:text-emerald-400'>
1033
+ ({t('edit.maskAppliedStatus')})
1034
  </span>
1035
  )}
1036
  <ScanEye className='mt-0.5' />
 
1228
  </div>
1229
  </div>
1230
  <p className='text-muted-foreground ui-stat text-xs'>
1231
+ {t(usesPositiveIntegerCustomSize ? 'form.pixelsMeta' : 'form.pixelsMetaOfMaximum', {
1232
  pixels: editCustomPixels.toLocaleString(locale),
1233
  percent: ((editCustomPixels / 8_294_400) * 100).toFixed(1),
1234
  ratio: editCustomRatio
 
1487
  <SelectItem value='server-default'>
1488
  {t('upstream.serverDefault')}
1489
  </SelectItem>
1490
+ <SelectItem value='none'>
1491
+ {t('upstream.thinkingNone')}
1492
+ </SelectItem>
1493
+ <SelectItem value='minimal'>
1494
+ {t('upstream.thinkingMinimal')}
1495
+ </SelectItem>
1496
+ <SelectItem value='low'>
1497
+ {t('upstream.thinkingLow')}
1498
+ </SelectItem>
1499
+ <SelectItem value='medium'>
1500
+ {t('upstream.thinkingMedium')}
1501
+ </SelectItem>
1502
+ <SelectItem value='high'>
1503
+ {t('upstream.thinkingHigh')}
1504
+ </SelectItem>
1505
+ <SelectItem value='xhigh'>
1506
+ {t('upstream.thinkingXhigh')}
1507
+ </SelectItem>
1508
  </SelectContent>
1509
  </Select>
1510
  </div>
 
1764
  <RadioItemWithIcon
1765
  value='png'
1766
  id='edit-format-png'
1767
+ label={t('common.png')}
1768
  Icon={FileImage}
1769
  disabled={isLoading}
1770
  />
1771
  <RadioItemWithIcon
1772
  value='jpeg'
1773
  id='edit-format-jpeg'
1774
+ label={t('common.jpeg')}
1775
  Icon={FileImage}
1776
  disabled={isLoading}
1777
  />
1778
  <RadioItemWithIcon
1779
  value='webp'
1780
  id='edit-format-webp'
1781
+ label={t('common.webp')}
1782
  Icon={FileImage}
1783
  disabled={isLoading}
1784
  />
 
1876
  </span>
1877
  )}
1878
  <span className='border-primary/20 bg-primary/10 text-primary col-span-2 inline-flex min-w-0 items-center justify-center rounded-md border px-2 py-1 text-center leading-4 break-words whitespace-normal'>
1879
+ {requestSummaryLabel}
1880
  </span>
1881
  </div>
1882
  {submitDisabledReason && (
src/components/generation-form.test.tsx CHANGED
@@ -142,7 +142,7 @@ function createGenerationFormProps(options: GenerationRenderOptions = {}): React
142
  setPromptOptimization: noop,
143
  forceWeb: false,
144
  setForceWeb: noop,
145
- estimatedCostLabel: '预计 0.12 积分',
146
  defaultAdvancedOpen: options.defaultAdvancedOpen,
147
  defaultAdvancedTab: options.defaultAdvancedTab
148
  };
@@ -283,10 +283,10 @@ describe('GenerationForm inactive state', { concurrency: false }, () => {
283
 
284
  try {
285
  const saveButton = [...view.container.querySelectorAll('button')].find((button) =>
286
- button.textContent?.includes('存为灵感')
287
  );
288
  const randomButton = [...view.container.querySelectorAll('button')].find((button) =>
289
- button.textContent?.includes('随便来点')
290
  );
291
 
292
  assert.ok(saveButton, 'missing generation save inspiration button');
@@ -321,7 +321,7 @@ describe('GenerationForm inactive state', { concurrency: false }, () => {
321
 
322
  try {
323
  const randomButton = [...view.container.querySelectorAll('button')].find((button) =>
324
- button.textContent?.includes('随便来点')
325
  );
326
 
327
  assert.ok(randomButton, 'missing batch random inspiration button');
@@ -353,7 +353,7 @@ describe('GenerationForm advanced groups', () => {
353
  it('shows task-specific descriptions in the mode segmented control', () => {
354
  const html = renderGenerationForm();
355
 
356
- for (const label of ['从灵感开始', '继续改', '一次多张', '套用旧稿']) {
357
  assert.match(html, new RegExp(label));
358
  }
359
  });
@@ -383,7 +383,7 @@ describe('GenerationForm advanced groups', () => {
383
  const html = renderGenerationForm();
384
 
385
  assert.match(html, /默认线路/);
386
- assert.match(html, /预计 0\.12 积分/);
387
  });
388
 
389
  it('labels the expanded mobile advanced drawer as professional mode', () => {
@@ -541,8 +541,8 @@ describe('GenerationForm advanced groups', () => {
541
  it('disables random inspiration when no saved prompt is available', () => {
542
  const html = renderGenerationForm({ canApplyRandomInspiration: false });
543
 
544
- assert.match(html, /随便来点/);
545
- assert.match(html, /<button[^>]*disabled=""[^>]*>[\s\S]*随便来点[\s\S]*<\/button>/);
546
  assert.doesNotMatch(html, /夏日窗边的奶油色房间/);
547
  });
548
 
 
142
  setPromptOptimization: noop,
143
  forceWeb: false,
144
  setForceWeb: noop,
145
+ requestSummaryLabel: '请求 1 张图片',
146
  defaultAdvancedOpen: options.defaultAdvancedOpen,
147
  defaultAdvancedTab: options.defaultAdvancedTab
148
  };
 
283
 
284
  try {
285
  const saveButton = [...view.container.querySelectorAll('button')].find((button) =>
286
+ button.textContent?.includes('收藏提示词')
287
  );
288
  const randomButton = [...view.container.querySelectorAll('button')].find((button) =>
289
+ button.textContent?.includes('随机套用')
290
  );
291
 
292
  assert.ok(saveButton, 'missing generation save inspiration button');
 
321
 
322
  try {
323
  const randomButton = [...view.container.querySelectorAll('button')].find((button) =>
324
+ button.textContent?.includes('随机套用')
325
  );
326
 
327
  assert.ok(randomButton, 'missing batch random inspiration button');
 
353
  it('shows task-specific descriptions in the mode segmented control', () => {
354
  const html = renderGenerationForm();
355
 
356
+ for (const label of ['从灵感开始', '基于参考编辑', '一次多张', '套用已有提示词']) {
357
  assert.match(html, new RegExp(label));
358
  }
359
  });
 
383
  const html = renderGenerationForm();
384
 
385
  assert.match(html, /默认线路/);
386
+ assert.match(html, /请求 1 张图片/);
387
  });
388
 
389
  it('labels the expanded mobile advanced drawer as professional mode', () => {
 
541
  it('disables random inspiration when no saved prompt is available', () => {
542
  const html = renderGenerationForm({ canApplyRandomInspiration: false });
543
 
544
+ assert.match(html, /随机套用/);
545
+ assert.match(html, /<button[^>]*disabled=""[^>]*>[\s\S]*随机套用[\s\S]*<\/button>/);
546
  assert.doesNotMatch(html, /夏日窗边的奶油色房间/);
547
  });
548
 
src/components/generation-form.tsx CHANGED
@@ -16,6 +16,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
16
  import { readBatchPromptLines } from '@/lib/batch-prompts';
17
  import type { GptImageModel } from '@/lib/cost-utils';
18
  import { useI18n } from '@/lib/i18n';
 
19
  import { shouldRecommendImageStreaming } from '@/lib/image-streaming-recommendation';
20
  import type {
21
  ImageUpstreamFormBackend,
@@ -173,7 +174,7 @@ type GenerationFormProps = {
173
  setPromptOptimization: React.Dispatch<React.SetStateAction<GenerationFormData['promptOptimization']>>;
174
  forceWeb: boolean;
175
  setForceWeb: React.Dispatch<React.SetStateAction<boolean>>;
176
- estimatedCostLabel: string;
177
  defaultAdvancedOpen?: boolean;
178
  defaultAdvancedTab?: AdvancedTab;
179
  };
@@ -311,19 +312,6 @@ function getStreamModeLabel(streamMode: ImageStreamMode, t: (key: string) => str
311
  return t('streaming.modeAuto');
312
  }
313
 
314
- function getQualityLabel(quality: GenerationFormData['quality'], t: (key: string) => string): string {
315
- if (quality === 'low') return t('common.low');
316
- if (quality === 'medium') return t('common.medium');
317
- if (quality === 'high') return t('common.high');
318
- return t('common.auto');
319
- }
320
-
321
- function getOutputFormatLabel(format: GenerationFormData['output_format'], t: (key: string) => string): string {
322
- if (format === 'jpeg') return t('common.jpeg');
323
- if (format === 'webp') return t('common.webp');
324
- return t('common.png');
325
- }
326
-
327
  export function GenerationForm({
328
  onSubmit,
329
  onSaveInspiration,
@@ -391,7 +379,7 @@ export function GenerationForm({
391
  setPromptOptimization,
392
  forceWeb,
393
  setForceWeb,
394
- estimatedCostLabel,
395
  defaultAdvancedOpen = false,
396
  defaultAdvancedTab = 'output'
397
  }: GenerationFormProps) {
@@ -471,8 +459,8 @@ export function GenerationForm({
471
  t
472
  ]);
473
  const advancedSummary = [
474
- `${t('form.quality')}: ${getQualityLabel(quality, t)}`,
475
- `${t('form.outputFormat')}: ${getOutputFormatLabel(outputFormat, t)}`,
476
  getBackendLabel(imageBackend, t)
477
  ].join(', ');
478
  const streamModeLabel = getStreamModeLabel(streamMode, t);
@@ -800,7 +788,7 @@ export function GenerationForm({
800
  </div>
801
  </div>
802
  <p className='text-muted-foreground ui-stat text-xs'>
803
- {t('form.pixelsMeta', {
804
  pixels: customPixels.toLocaleString(locale),
805
  percent: ((customPixels / 8_294_400) * 100).toFixed(1),
806
  ratio: customRatio
@@ -888,21 +876,21 @@ export function GenerationForm({
888
  <RadioItemWithIcon
889
  value='jpeg'
890
  id='format-jpeg-quick'
891
- label='JPG'
892
  Icon={FileImage}
893
  disabled={isLoading}
894
  />
895
  <RadioItemWithIcon
896
  value='png'
897
  id='format-png-quick'
898
- label='PNG'
899
  Icon={FileImage}
900
  disabled={isLoading}
901
  />
902
  <RadioItemWithIcon
903
  value='webp'
904
  id='format-webp-quick'
905
- label='WEBP'
906
  Icon={FileImage}
907
  disabled={isLoading}
908
  />
@@ -1134,12 +1122,24 @@ export function GenerationForm({
1134
  <SelectItem value='server-default'>
1135
  {t('upstream.serverDefault')}
1136
  </SelectItem>
1137
- <SelectItem value='none'>none</SelectItem>
1138
- <SelectItem value='minimal'>minimal</SelectItem>
1139
- <SelectItem value='low'>low</SelectItem>
1140
- <SelectItem value='medium'>medium</SelectItem>
1141
- <SelectItem value='high'>high</SelectItem>
1142
- <SelectItem value='xhigh'>xhigh</SelectItem>
 
 
 
 
 
 
 
 
 
 
 
 
1143
  </SelectContent>
1144
  </Select>
1145
  </div>
@@ -1437,21 +1437,21 @@ export function GenerationForm({
1437
  <RadioItemWithIcon
1438
  value='png'
1439
  id='format-png'
1440
- label='PNG'
1441
  Icon={FileImage}
1442
  disabled={isLoading}
1443
  />
1444
  <RadioItemWithIcon
1445
  value='jpeg'
1446
  id='format-jpeg'
1447
- label='JPEG'
1448
  Icon={FileImage}
1449
  disabled={isLoading}
1450
  />
1451
  <RadioItemWithIcon
1452
  value='webp'
1453
  id='format-webp'
1454
- label='WebP'
1455
  Icon={FileImage}
1456
  disabled={isLoading}
1457
  />
@@ -1531,7 +1531,7 @@ export function GenerationForm({
1531
  </span>
1532
  )}
1533
  <span className='border-primary/20 bg-primary/10 text-primary col-span-2 inline-flex min-w-0 items-center justify-center rounded-md border px-2 py-1 text-center leading-4 break-words whitespace-normal'>
1534
- {estimatedCostLabel}
1535
  </span>
1536
  </div>
1537
  {submitDisabledReason && (
 
16
  import { readBatchPromptLines } from '@/lib/batch-prompts';
17
  import type { GptImageModel } from '@/lib/cost-utils';
18
  import { useI18n } from '@/lib/i18n';
19
+ import { getImageOutputFormatLabel, getImageQualityLabel } from '@/lib/image-display-labels';
20
  import { shouldRecommendImageStreaming } from '@/lib/image-streaming-recommendation';
21
  import type {
22
  ImageUpstreamFormBackend,
 
174
  setPromptOptimization: React.Dispatch<React.SetStateAction<GenerationFormData['promptOptimization']>>;
175
  forceWeb: boolean;
176
  setForceWeb: React.Dispatch<React.SetStateAction<boolean>>;
177
+ requestSummaryLabel: string;
178
  defaultAdvancedOpen?: boolean;
179
  defaultAdvancedTab?: AdvancedTab;
180
  };
 
312
  return t('streaming.modeAuto');
313
  }
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  export function GenerationForm({
316
  onSubmit,
317
  onSaveInspiration,
 
379
  setPromptOptimization,
380
  forceWeb,
381
  setForceWeb,
382
+ requestSummaryLabel,
383
  defaultAdvancedOpen = false,
384
  defaultAdvancedTab = 'output'
385
  }: GenerationFormProps) {
 
459
  t
460
  ]);
461
  const advancedSummary = [
462
+ `${t('form.quality')}: ${getImageQualityLabel(quality, t)}`,
463
+ `${t('form.outputFormat')}: ${getImageOutputFormatLabel(outputFormat, t)}`,
464
  getBackendLabel(imageBackend, t)
465
  ].join(', ');
466
  const streamModeLabel = getStreamModeLabel(streamMode, t);
 
788
  </div>
789
  </div>
790
  <p className='text-muted-foreground ui-stat text-xs'>
791
+ {t(usesPositiveIntegerCustomSize ? 'form.pixelsMeta' : 'form.pixelsMetaOfMaximum', {
792
  pixels: customPixels.toLocaleString(locale),
793
  percent: ((customPixels / 8_294_400) * 100).toFixed(1),
794
  ratio: customRatio
 
876
  <RadioItemWithIcon
877
  value='jpeg'
878
  id='format-jpeg-quick'
879
+ label={t('common.jpeg')}
880
  Icon={FileImage}
881
  disabled={isLoading}
882
  />
883
  <RadioItemWithIcon
884
  value='png'
885
  id='format-png-quick'
886
+ label={t('common.png')}
887
  Icon={FileImage}
888
  disabled={isLoading}
889
  />
890
  <RadioItemWithIcon
891
  value='webp'
892
  id='format-webp-quick'
893
+ label={t('common.webp')}
894
  Icon={FileImage}
895
  disabled={isLoading}
896
  />
 
1122
  <SelectItem value='server-default'>
1123
  {t('upstream.serverDefault')}
1124
  </SelectItem>
1125
+ <SelectItem value='none'>
1126
+ {t('upstream.thinkingNone')}
1127
+ </SelectItem>
1128
+ <SelectItem value='minimal'>
1129
+ {t('upstream.thinkingMinimal')}
1130
+ </SelectItem>
1131
+ <SelectItem value='low'>
1132
+ {t('upstream.thinkingLow')}
1133
+ </SelectItem>
1134
+ <SelectItem value='medium'>
1135
+ {t('upstream.thinkingMedium')}
1136
+ </SelectItem>
1137
+ <SelectItem value='high'>
1138
+ {t('upstream.thinkingHigh')}
1139
+ </SelectItem>
1140
+ <SelectItem value='xhigh'>
1141
+ {t('upstream.thinkingXhigh')}
1142
+ </SelectItem>
1143
  </SelectContent>
1144
  </Select>
1145
  </div>
 
1437
  <RadioItemWithIcon
1438
  value='png'
1439
  id='format-png'
1440
+ label={t('common.png')}
1441
  Icon={FileImage}
1442
  disabled={isLoading}
1443
  />
1444
  <RadioItemWithIcon
1445
  value='jpeg'
1446
  id='format-jpeg'
1447
+ label={t('common.jpeg')}
1448
  Icon={FileImage}
1449
  disabled={isLoading}
1450
  />
1451
  <RadioItemWithIcon
1452
  value='webp'
1453
  id='format-webp'
1454
+ label={t('common.webp')}
1455
  Icon={FileImage}
1456
  disabled={isLoading}
1457
  />
 
1531
  </span>
1532
  )}
1533
  <span className='border-primary/20 bg-primary/10 text-primary col-span-2 inline-flex min-w-0 items-center justify-center rounded-md border px-2 py-1 text-center leading-4 break-words whitespace-normal'>
1534
+ {requestSummaryLabel}
1535
  </span>
1536
  </div>
1537
  {submitDisabledReason && (
src/components/history-panel.test.tsx CHANGED
@@ -176,7 +176,9 @@ describe('HistoryPanel recent history actions', () => {
176
 
177
  assert.match(html, /最近生成/);
178
  assert.match(html, /用户真实历史提示词/);
179
- assert.match(html, /数据库/);
 
 
180
  assert.doesNotMatch(html, /Album/);
181
  assert.doesNotMatch(html, /Local/);
182
  assert.match(html, /收藏这条历史提示词/);
@@ -188,6 +190,20 @@ describe('HistoryPanel recent history actions', () => {
188
  assert.match(html, /aria-label="标记本次结果可用"/);
189
  assert.match(html, /aria-label="标记本次结果需要修改"/);
190
  assert.doesNotMatch(html, /2xl:grid-cols-2/);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  });
192
 
193
  it('renders permanent-save selection controls only for fs history when cleanup is enabled', () => {
@@ -198,7 +214,7 @@ describe('HistoryPanel recent history actions', () => {
198
  });
199
 
200
  assert.match(html, /aria-label="选择最近生成图片"/);
201
- assert.match(html, /aria-label="已永久保存"/);
202
  assert.doesNotMatch(html, /data-retention-image="history-card\.png"/);
203
 
204
  const disabledHtml = renderHistoryPanel([fsHistoryItem], [], [], false, {
@@ -237,7 +253,7 @@ describe('HistoryPanel recent history actions', () => {
237
  await view.click(checkbox);
238
 
239
  const preserveButton = [...view.container.querySelectorAll<HTMLButtonElement>('button')].find(
240
- (button) => button.textContent === '永久保存'
241
  );
242
  assert.ok(preserveButton, 'missing preserve action');
243
  await view.click(preserveButton);
@@ -286,7 +302,7 @@ describe('HistoryPanel recent history actions', () => {
286
  );
287
  assert.equal(retainedCheckbox?.getAttribute('data-state'), 'checked');
288
  const preserveButton = [...view.container.querySelectorAll<HTMLButtonElement>('button')].find(
289
- (button) => button.textContent === '永久保存'
290
  );
291
  assert.ok(preserveButton, 'missing preserve action after history update');
292
  await view.click(preserveButton);
@@ -332,7 +348,7 @@ describe('HistoryPanel recent history actions', () => {
332
 
333
  assert.match(view.container.textContent ?? '', /已选择 0 张/);
334
  const preserveButton = [...view.container.querySelectorAll<HTMLButtonElement>('button')].find(
335
- (button) => button.textContent === '永久保存'
336
  );
337
  assert.equal(preserveButton?.disabled, true);
338
  assert.deepEqual(updates, []);
@@ -351,7 +367,7 @@ describe('HistoryPanel recent history actions', () => {
351
  cleanupEnabled: true,
352
  permanentlySavedFilenames: new Set(),
353
  onUpdatePermanentSave: async () => {
354
- throw new Error('部分图片的永久存状态未更新。');
355
  }
356
  })
357
  );
@@ -366,12 +382,12 @@ describe('HistoryPanel recent history actions', () => {
366
  await view.click(checkbox);
367
 
368
  const preserveButton = [...view.container.querySelectorAll<HTMLButtonElement>('button')].find(
369
- (button) => button.textContent === '永久保存'
370
  );
371
  assert.ok(preserveButton, 'missing preserve action');
372
  await view.click(preserveButton);
373
 
374
- assert.match(view.container.textContent ?? '', /部分图片的永久存状态未更新。/);
375
  assert.equal(checkbox.getAttribute('data-state'), 'checked');
376
  } finally {
377
  await view?.cleanup();
@@ -423,6 +439,19 @@ describe('HistoryPanel recent history actions', () => {
423
  assert.match(source, /text_input_tokens\.toLocaleString\(\s*locale\s*\)/);
424
  assert.match(source, /image_input_tokens\.toLocaleString\(\s*locale\s*\)/);
425
  assert.match(source, /image_output_tokens\.toLocaleString\(\s*locale\s*\)/);
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  });
427
 
428
  it('renders saved result feedback on completed history cards', () => {
@@ -495,7 +524,7 @@ describe('HistoryPanel recent history actions', () => {
495
  assert.match(html, /snap-x snap-mandatory/);
496
  assert.match(html, /w-\[min\(84vw,360px\)\]/);
497
  assert.match(html, /用户保存的真实灵感提示词/);
498
- assert.match(html, /aria-label="套用灵感:用户保存的真实灵感提示词"/);
499
  assert.doesNotMatch(html, /窗边的花与书/);
500
  assert.doesNotMatch(html, /inspiration-flowers/);
501
  });
@@ -503,9 +532,9 @@ describe('HistoryPanel recent history actions', () => {
503
  it('does not render fake inspiration actions without handlers', () => {
504
  const html = renderHistoryPanel([], [inspirationItem]);
505
 
506
- assert.match(html, /已保存的灵感/);
507
- assert.match(html, /套用首个模板/);
508
- assert.match(html, /1 条灵感/);
509
  assert.doesNotMatch(html, /aria-label="收藏"/);
510
  assert.doesNotMatch(html, />管理</);
511
  });
@@ -518,7 +547,7 @@ describe('HistoryPanel recent history actions', () => {
518
  assert.match(html, /xl:flex xl:max-h-none xl:flex-1 xl:flex-col/);
519
  assert.doesNotMatch(html, /xl:h-auto xl:self-start/);
520
  assert.doesNotMatch(html, /border-dashed/);
521
- assert.match(html, /暂无已保存的灵感/);
522
  assert.doesNotMatch(html, /activity-feed/);
523
  assert.doesNotMatch(html, /生成动态/);
524
  });
 
176
 
177
  assert.match(html, /最近生成/);
178
  assert.match(html, /用户真实历史提示词/);
179
+ assert.match(html, /浏览器本地数据库/);
180
+ assert.match(html, /1\.4 秒/);
181
+ assert.match(html, /收藏提示词/);
182
  assert.doesNotMatch(html, /Album/);
183
  assert.doesNotMatch(html, /Local/);
184
  assert.match(html, /收藏这条历史提示词/);
 
190
  assert.match(html, /aria-label="标记本次结果可用"/);
191
  assert.match(html, /aria-label="标记本次结果需要修改"/);
192
  assert.doesNotMatch(html, /2xl:grid-cols-2/);
193
+
194
+ const fsHtml = renderHistoryPanel([fsHistoryItem]);
195
+ assert.match(fsHtml, /服务器文件存储/);
196
+ });
197
+
198
+ it('renders persisted request enums with localized labels', () => {
199
+ const html = renderHistoryPanel([historyItem]);
200
+
201
+ assert.match(html, /清晰度:<\/span> 高/);
202
+ assert.match(html, /背景:<\/span> 自动/);
203
+ assert.match(html, /审核级别:<\/span> 自动/);
204
+ assert.match(html, />PNG<\/span>/);
205
+ assert.doesNotMatch(html, /清晰度:<\/span> high/);
206
+ assert.doesNotMatch(html, /背景:<\/span> auto/);
207
  });
208
 
209
  it('renders permanent-save selection controls only for fs history when cleanup is enabled', () => {
 
214
  });
215
 
216
  assert.match(html, /aria-label="选择最近生成图片"/);
217
+ assert.match(html, /aria-label="已免于自动清理"/);
218
  assert.doesNotMatch(html, /data-retention-image="history-card\.png"/);
219
 
220
  const disabledHtml = renderHistoryPanel([fsHistoryItem], [], [], false, {
 
253
  await view.click(checkbox);
254
 
255
  const preserveButton = [...view.container.querySelectorAll<HTMLButtonElement>('button')].find(
256
+ (button) => button.textContent === '设为免于自动清理'
257
  );
258
  assert.ok(preserveButton, 'missing preserve action');
259
  await view.click(preserveButton);
 
302
  );
303
  assert.equal(retainedCheckbox?.getAttribute('data-state'), 'checked');
304
  const preserveButton = [...view.container.querySelectorAll<HTMLButtonElement>('button')].find(
305
+ (button) => button.textContent === '设为免于自动清理'
306
  );
307
  assert.ok(preserveButton, 'missing preserve action after history update');
308
  await view.click(preserveButton);
 
348
 
349
  assert.match(view.container.textContent ?? '', /已选择 0 张/);
350
  const preserveButton = [...view.container.querySelectorAll<HTMLButtonElement>('button')].find(
351
+ (button) => button.textContent === '设为免于自动清理'
352
  );
353
  assert.equal(preserveButton?.disabled, true);
354
  assert.deepEqual(updates, []);
 
367
  cleanupEnabled: true,
368
  permanentlySavedFilenames: new Set(),
369
  onUpdatePermanentSave: async () => {
370
+ throw new Error('部分图片的自动清理未更新。');
371
  }
372
  })
373
  );
 
382
  await view.click(checkbox);
383
 
384
  const preserveButton = [...view.container.querySelectorAll<HTMLButtonElement>('button')].find(
385
+ (button) => button.textContent === '设为免于自动清理'
386
  );
387
  assert.ok(preserveButton, 'missing preserve action');
388
  await view.click(preserveButton);
389
 
390
+ assert.match(view.container.textContent ?? '', /部分图片的自动清理未更新。/);
391
  assert.equal(checkbox.getAttribute('data-state'), 'checked');
392
  } finally {
393
  await view?.cleanup();
 
439
  assert.match(source, /text_input_tokens\.toLocaleString\(\s*locale\s*\)/);
440
  assert.match(source, /image_input_tokens\.toLocaleString\(\s*locale\s*\)/);
441
  assert.match(source, /image_output_tokens\.toLocaleString\(\s*locale\s*\)/);
442
+ assert.match(source, /formatImageDurationLabel\(durationMs, locale, t\)/);
443
+ assert.match(source, /getStorageLabel\(originalStorageMode, t\)/);
444
+ });
445
+
446
+ it('localizes actual-cost diagnostics instead of rendering internal values', async () => {
447
+ const source = await readFile(new URL('./history-panel.tsx', import.meta.url), 'utf8');
448
+
449
+ assert.match(source, /getActualCostSourceLabel\(/);
450
+ assert.match(source, /getActualCostConfidenceLabel\(/);
451
+ assert.match(source, /getActualCostReasonLabel\(/);
452
+ assert.doesNotMatch(source, /\{item\.actualCostDetails\.source\}/);
453
+ assert.doesNotMatch(source, /\{item\.actualCostDetails\.confidence\}/);
454
+ assert.doesNotMatch(source, /\{item\.actualCostDetails\.reason\}/);
455
  });
456
 
457
  it('renders saved result feedback on completed history cards', () => {
 
524
  assert.match(html, /snap-x snap-mandatory/);
525
  assert.match(html, /w-\[min\(84vw,360px\)\]/);
526
  assert.match(html, /用户保存的真实灵感提示词/);
527
+ assert.match(html, /aria-label="套用提示词:用户保存的真实灵感提示词"/);
528
  assert.doesNotMatch(html, /窗边的花与书/);
529
  assert.doesNotMatch(html, /inspiration-flowers/);
530
  });
 
532
  it('does not render fake inspiration actions without handlers', () => {
533
  const html = renderHistoryPanel([], [inspirationItem]);
534
 
535
+ assert.match(html, /已保存的提示词/);
536
+ assert.match(html, /套用首条提示词/);
537
+ assert.match(html, /已保存 1 条提示词/);
538
  assert.doesNotMatch(html, /aria-label="收藏"/);
539
  assert.doesNotMatch(html, />管理</);
540
  });
 
547
  assert.match(html, /xl:flex xl:max-h-none xl:flex-1 xl:flex-col/);
548
  assert.doesNotMatch(html, /xl:h-auto xl:self-start/);
549
  assert.doesNotMatch(html, /border-dashed/);
550
+ assert.match(html, /暂无已保存的提示词/);
551
  assert.doesNotMatch(html, /activity-feed/);
552
  assert.doesNotMatch(html, /生成动态/);
553
  });
src/components/history-panel.tsx CHANGED
@@ -24,6 +24,13 @@ import {
24
  type ResultFeedbackValue
25
  } from '@/lib/history-metadata';
26
  import { useI18n } from '@/lib/i18n';
 
 
 
 
 
 
 
27
  import { cn } from '@/lib/utils';
28
  import {
29
  Copy,
@@ -110,33 +117,16 @@ export type PromptApplySource =
110
 
111
  const emptyPermanentFilenames: ReadonlySet<string> = new Set();
112
 
113
- const formatDuration = (ms: number): string => {
114
- if (ms < 1000) {
115
- return `${ms}ms`;
116
- }
117
- const totalSeconds = Math.round(ms / 1000);
118
- if (totalSeconds < 60) {
119
- return `${(ms / 1000).toFixed(1)}s`;
120
- }
121
-
122
- const hours = Math.floor(totalSeconds / 3600);
123
- const minutes = Math.floor((totalSeconds % 3600) / 60);
124
- const seconds = totalSeconds % 60;
125
- const paddedSeconds = seconds.toString().padStart(2, '0');
126
-
127
- if (hours > 0) {
128
- return `${hours}h ${minutes.toString().padStart(2, '0')}m ${paddedSeconds}s`;
129
- }
130
- return `${minutes}m ${paddedSeconds}s`;
131
- };
132
-
133
- const calculateCost = (value: number, rate: number): string => {
134
  const cost = value * rate;
135
- return isNaN(cost) ? 'N/A' : cost.toFixed(4);
136
  };
137
 
138
  const formatMoney = (value: number): string => value.toFixed(4);
139
- const formatEstimatedTokenCost = (value: number, rate: number): string => `$${calculateCost(value, rate)}`;
 
 
 
140
 
141
  function getStorageLabel(storageMode: HistoryMetadata['storageModeUsed'], t: ReturnType<typeof useI18n>['t']): string {
142
  return storageMode === 'fs' ? t('history.storageFile') : t('history.storageDb');
@@ -195,6 +185,32 @@ function getCostStatusLabel(
195
  return '-';
196
  }
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  function getHistoryStorageMode(item: HistoryMetadata): NonNullable<HistoryMetadata['storageModeUsed']> {
199
  return item.storageModeUsed ?? 'fs';
200
  }
@@ -324,7 +340,7 @@ function HistoryPanelImpl({
324
  await onUpdatePermanentSave(action, filenames);
325
  setSelectedRetentionFilenames(new Set());
326
  } catch (error) {
327
- console.error('更新图片永久存状态失败:', error);
328
  setRetentionError(
329
  error instanceof Error && error.message ? error.message : t('retention.updateFailed')
330
  );
@@ -357,6 +373,10 @@ function HistoryPanelImpl({
357
  }),
358
  [locale]
359
  );
 
 
 
 
360
  const handleThumbnailLoad = React.useCallback((filename: string, event: React.SyntheticEvent<HTMLImageElement>) => {
361
  const image = event.currentTarget;
362
  if (!image.naturalWidth || !image.naturalHeight) return;
@@ -691,6 +711,9 @@ function HistoryPanelImpl({
691
  unavailable: t('history.actualCostUnavailable'),
692
  estimated: t('history.estimatedCostShort')
693
  });
 
 
 
694
 
695
  let thumbnailUrl: string | undefined;
696
  if (firstImage) {
@@ -800,7 +823,7 @@ function HistoryPanelImpl({
800
  {item.output_format && (
801
  <div className='bg-background/85 text-muted-foreground border-border flex items-center gap-1 rounded-sm border px-1 py-0.5 text-[11px]'>
802
  <FileImage size={12} className='text-muted-foreground' />
803
- <span>{outputFormat.toUpperCase()}</span>
804
  </div>
805
  )}
806
  </div>
@@ -909,7 +932,10 @@ function HistoryPanelImpl({
909
  <div className='flex justify-between gap-3'>
910
  <span>{t('history.costSource')}</span>
911
  <span className='text-right'>
912
- {item.actualCostDetails.source}
 
 
 
913
  </span>
914
  </div>
915
  <div className='flex justify-between gap-3'>
@@ -917,7 +943,11 @@ function HistoryPanelImpl({
917
  {t('history.costConfidence')}
918
  </span>
919
  <span>
920
- {item.actualCostDetails.confidence}
 
 
 
 
921
  </span>
922
  </div>
923
  {typeof item.actualCostDetails
@@ -947,9 +977,9 @@ function HistoryPanelImpl({
947
  </span>
948
  </div>
949
  )}
950
- {item.actualCostDetails.reason && (
951
  <p className='text-xs'>
952
- {item.actualCostDetails.reason}
953
  </p>
954
  )}
955
  </div>
@@ -968,7 +998,8 @@ function HistoryPanelImpl({
968
  {formatEstimatedTokenCost(
969
  item.costDetails
970
  .text_input_tokens,
971
- rates.textInputPerToken
 
972
  )}
973
  )
974
  </span>
@@ -987,7 +1018,8 @@ function HistoryPanelImpl({
987
  {formatEstimatedTokenCost(
988
  item.costDetails
989
  .image_input_tokens,
990
- rates.imageInputPerToken
 
991
  )}
992
  )
993
  </span>
@@ -1005,7 +1037,8 @@ function HistoryPanelImpl({
1005
  {formatEstimatedTokenCost(
1006
  item.costDetails
1007
  .image_output_tokens,
1008
- rates.imageOutputPerToken
 
1009
  )}
1010
  )
1011
  </span>
@@ -1052,7 +1085,10 @@ function HistoryPanelImpl({
1052
  {formatStatusTime(item.timestamp)}
1053
  <span className='text-muted-foreground/70 px-1'>/</span>
1054
  {isFailedItem
1055
- ? `${t('history.failedStatus')},${formatDuration(item.durationMs)}`
 
 
 
1056
  : t('history.statusBatchSummary', {
1057
  count: imageCount,
1058
  duration: formatDuration(item.durationMs)
@@ -1076,15 +1112,15 @@ function HistoryPanelImpl({
1076
  </p>
1077
  <p>
1078
  <span className='text-foreground font-medium'>{t('history.quality')}</span>{' '}
1079
- {item.quality}
1080
  </p>
1081
  <p>
1082
  <span className='text-foreground font-medium'>{t('history.bg')}</span>{' '}
1083
- {item.background}
1084
  </p>
1085
  <p>
1086
  <span className='text-foreground font-medium'>{t('history.mod')}</span>{' '}
1087
- {item.moderation}
1088
  </p>
1089
  <p
1090
  className='mt-2 max-h-10 min-h-8 overflow-hidden leading-5 break-words'
@@ -1401,7 +1437,7 @@ function HistoryPanelImpl({
1401
  {t('history.quality')}
1402
  </dt>
1403
  <dd className='text-foreground font-medium'>
1404
- {item.quality}
1405
  </dd>
1406
  </div>
1407
  <div>
@@ -1409,7 +1445,7 @@ function HistoryPanelImpl({
1409
  {t('history.bg')}
1410
  </dt>
1411
  <dd className='text-foreground font-medium'>
1412
- {item.background}
1413
  </dd>
1414
  </div>
1415
  <div>
@@ -1417,7 +1453,7 @@ function HistoryPanelImpl({
1417
  {t('history.mod')}
1418
  </dt>
1419
  <dd className='text-foreground font-medium'>
1420
- {item.moderation}
1421
  </dd>
1422
  </div>
1423
  <div>
@@ -1433,7 +1469,7 @@ function HistoryPanelImpl({
1433
  {t('history.outputFormat')}
1434
  </dt>
1435
  <dd className='text-foreground font-medium'>
1436
- {outputFormat.toUpperCase()}
1437
  </dd>
1438
  </div>
1439
  <div>
@@ -1441,9 +1477,7 @@ function HistoryPanelImpl({
1441
  {t('history.storage')}
1442
  </dt>
1443
  <dd className='text-foreground font-medium'>
1444
- {originalStorageMode === 'fs'
1445
- ? t('history.storageFile')
1446
- : t('history.storageDb')}
1447
  </dd>
1448
  </div>
1449
  <div>
 
24
  type ResultFeedbackValue
25
  } from '@/lib/history-metadata';
26
  import { useI18n } from '@/lib/i18n';
27
+ import {
28
+ formatImageDurationLabel,
29
+ getImageBackgroundLabel,
30
+ getImageModerationLabel,
31
+ getImageOutputFormatLabel,
32
+ getImageQualityLabel
33
+ } from '@/lib/image-display-labels';
34
  import { cn } from '@/lib/utils';
35
  import {
36
  Copy,
 
117
 
118
  const emptyPermanentFilenames: ReadonlySet<string> = new Set();
119
 
120
+ const calculateCost = (value: number, rate: number): string | null => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  const cost = value * rate;
122
+ return Number.isFinite(cost) ? cost.toFixed(4) : null;
123
  };
124
 
125
  const formatMoney = (value: number): string => value.toFixed(4);
126
+ const formatEstimatedTokenCost = (value: number, rate: number, unavailableLabel: string): string => {
127
+ const cost = calculateCost(value, rate);
128
+ return cost === null ? unavailableLabel : `$${cost}`;
129
+ };
130
 
131
  function getStorageLabel(storageMode: HistoryMetadata['storageModeUsed'], t: ReturnType<typeof useI18n>['t']): string {
132
  return storageMode === 'fs' ? t('history.storageFile') : t('history.storageDb');
 
185
  return '-';
186
  }
187
 
188
+ function getActualCostSourceLabel(source: string, t: ReturnType<typeof useI18n>['t']): string {
189
+ if (source === 'new-api-log-token') return t('history.costSourceMatchedLog');
190
+ if (source === 'pending') return t('history.costSourcePending');
191
+ if (source === 'unavailable') return t('history.costSourceUnavailable');
192
+ if (source === 'estimate') return t('history.costSourceEstimate');
193
+ return source;
194
+ }
195
+
196
+ function getActualCostConfidenceLabel(confidence: string, t: ReturnType<typeof useI18n>['t']): string {
197
+ if (confidence === 'exact') return t('history.costConfidenceExact');
198
+ if (confidence === 'high') return t('history.costConfidenceHigh');
199
+ if (confidence === 'low') return t('history.costConfidenceLow');
200
+ if (confidence === 'none') return t('history.costConfidenceNone');
201
+ return confidence;
202
+ }
203
+
204
+ function getActualCostReasonLabel(
205
+ details: NonNullable<HistoryMetadata['actualCostDetails']>,
206
+ t: ReturnType<typeof useI18n>['t']
207
+ ): string | null {
208
+ if (details.source === 'pending') return t('history.costReasonPending');
209
+ if (details.source === 'unavailable') return t('history.costReasonUnavailable');
210
+ if (details.confidence === 'low' || details.confidence === 'none') return t('history.costReasonLowConfidence');
211
+ return null;
212
+ }
213
+
214
  function getHistoryStorageMode(item: HistoryMetadata): NonNullable<HistoryMetadata['storageModeUsed']> {
215
  return item.storageModeUsed ?? 'fs';
216
  }
 
340
  await onUpdatePermanentSave(action, filenames);
341
  setSelectedRetentionFilenames(new Set());
342
  } catch (error) {
343
+ console.error('更新图片自动清理失败:', error);
344
  setRetentionError(
345
  error instanceof Error && error.message ? error.message : t('retention.updateFailed')
346
  );
 
373
  }),
374
  [locale]
375
  );
376
+ const formatDuration = React.useCallback(
377
+ (durationMs: number) => formatImageDurationLabel(durationMs, locale, t),
378
+ [locale, t]
379
+ );
380
  const handleThumbnailLoad = React.useCallback((filename: string, event: React.SyntheticEvent<HTMLImageElement>) => {
381
  const image = event.currentTarget;
382
  if (!image.naturalWidth || !image.naturalHeight) return;
 
711
  unavailable: t('history.actualCostUnavailable'),
712
  estimated: t('history.estimatedCostShort')
713
  });
714
+ const actualCostReason = item.actualCostDetails
715
+ ? getActualCostReasonLabel(item.actualCostDetails, t)
716
+ : null;
717
 
718
  let thumbnailUrl: string | undefined;
719
  if (firstImage) {
 
823
  {item.output_format && (
824
  <div className='bg-background/85 text-muted-foreground border-border flex items-center gap-1 rounded-sm border px-1 py-0.5 text-[11px]'>
825
  <FileImage size={12} className='text-muted-foreground' />
826
+ <span>{getImageOutputFormatLabel(outputFormat, t)}</span>
827
  </div>
828
  )}
829
  </div>
 
932
  <div className='flex justify-between gap-3'>
933
  <span>{t('history.costSource')}</span>
934
  <span className='text-right'>
935
+ {getActualCostSourceLabel(
936
+ item.actualCostDetails.source,
937
+ t
938
+ )}
939
  </span>
940
  </div>
941
  <div className='flex justify-between gap-3'>
 
943
  {t('history.costConfidence')}
944
  </span>
945
  <span>
946
+ {getActualCostConfidenceLabel(
947
+ item.actualCostDetails
948
+ .confidence,
949
+ t
950
+ )}
951
  </span>
952
  </div>
953
  {typeof item.actualCostDetails
 
977
  </span>
978
  </div>
979
  )}
980
+ {actualCostReason && (
981
  <p className='text-xs'>
982
+ {actualCostReason}
983
  </p>
984
  )}
985
  </div>
 
998
  {formatEstimatedTokenCost(
999
  item.costDetails
1000
  .text_input_tokens,
1001
+ rates.textInputPerToken,
1002
+ t('common.unavailable')
1003
  )}
1004
  )
1005
  </span>
 
1018
  {formatEstimatedTokenCost(
1019
  item.costDetails
1020
  .image_input_tokens,
1021
+ rates.imageInputPerToken,
1022
+ t('common.unavailable')
1023
  )}
1024
  )
1025
  </span>
 
1037
  {formatEstimatedTokenCost(
1038
  item.costDetails
1039
  .image_output_tokens,
1040
+ rates.imageOutputPerToken,
1041
+ t('common.unavailable')
1042
  )}
1043
  )
1044
  </span>
 
1085
  {formatStatusTime(item.timestamp)}
1086
  <span className='text-muted-foreground/70 px-1'>/</span>
1087
  {isFailedItem
1088
+ ? t('history.statusFailedSummary', {
1089
+ status: t('history.failedStatus'),
1090
+ duration: formatDuration(item.durationMs)
1091
+ })
1092
  : t('history.statusBatchSummary', {
1093
  count: imageCount,
1094
  duration: formatDuration(item.durationMs)
 
1112
  </p>
1113
  <p>
1114
  <span className='text-foreground font-medium'>{t('history.quality')}</span>{' '}
1115
+ {getImageQualityLabel(item.quality, t)}
1116
  </p>
1117
  <p>
1118
  <span className='text-foreground font-medium'>{t('history.bg')}</span>{' '}
1119
+ {getImageBackgroundLabel(item.background, t)}
1120
  </p>
1121
  <p>
1122
  <span className='text-foreground font-medium'>{t('history.mod')}</span>{' '}
1123
+ {getImageModerationLabel(item.moderation, t)}
1124
  </p>
1125
  <p
1126
  className='mt-2 max-h-10 min-h-8 overflow-hidden leading-5 break-words'
 
1437
  {t('history.quality')}
1438
  </dt>
1439
  <dd className='text-foreground font-medium'>
1440
+ {getImageQualityLabel(item.quality, t)}
1441
  </dd>
1442
  </div>
1443
  <div>
 
1445
  {t('history.bg')}
1446
  </dt>
1447
  <dd className='text-foreground font-medium'>
1448
+ {getImageBackgroundLabel(item.background, t)}
1449
  </dd>
1450
  </div>
1451
  <div>
 
1453
  {t('history.mod')}
1454
  </dt>
1455
  <dd className='text-foreground font-medium'>
1456
+ {getImageModerationLabel(item.moderation, t)}
1457
  </dd>
1458
  </div>
1459
  <div>
 
1469
  {t('history.outputFormat')}
1470
  </dt>
1471
  <dd className='text-foreground font-medium'>
1472
+ {getImageOutputFormatLabel(outputFormat, t)}
1473
  </dd>
1474
  </div>
1475
  <div>
 
1477
  {t('history.storage')}
1478
  </dt>
1479
  <dd className='text-foreground font-medium'>
1480
+ {getStorageLabel(originalStorageMode, t)}
 
 
1481
  </dd>
1482
  </div>
1483
  <div>
src/components/image-output.test.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { buildImageActionTarget, ImageOutput } from './image-output';
2
  import { I18nProvider } from '@/lib/i18n';
3
  import assert from 'node:assert/strict';
4
  import { describe, it } from 'node:test';
@@ -43,6 +43,21 @@ describe('ImageOutput result actions', () => {
43
  assert.doesNotMatch(html, /1024 x 768/);
44
  });
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  it('uses the no-image state in the preview header before generation', () => {
47
  const html = renderToStaticMarkup(
48
  <I18nProvider>
@@ -81,7 +96,7 @@ describe('ImageOutput result actions', () => {
81
  assert.doesNotMatch(html, /workbench-sample/);
82
  assert.doesNotMatch(html, /灵感样张/);
83
  assert.doesNotMatch(html, /1024 x 768/);
84
- for (const action of ['继续编辑', '做变体', '复用提示词', '对比', '下载']) {
85
  assert.match(
86
  html,
87
  new RegExp(
 
1
+ import { buildImageActionTarget, formatLogTime, ImageOutput } from './image-output';
2
  import { I18nProvider } from '@/lib/i18n';
3
  import assert from 'node:assert/strict';
4
  import { describe, it } from 'node:test';
 
43
  assert.doesNotMatch(html, /1024 x 768/);
44
  });
45
 
46
+ it('uses the localized generated-image alt text when a caller does not override it', () => {
47
+ const html = renderImageOutput(0);
48
+
49
+ assert.match(html, /alt="生成图片输出"/);
50
+ assert.doesNotMatch(html, /Generated image output/);
51
+ });
52
+
53
+ it('formats activity timestamps with the selected app locale and preserves invalid diagnostic values', () => {
54
+ const timestamp = '2026-05-31T12:15:30.000Z';
55
+
56
+ assert.equal(formatLogTime(timestamp, 'zh-CN'), new Date(timestamp).toLocaleTimeString('zh-CN'));
57
+ assert.equal(formatLogTime(timestamp, 'en-US'), new Date(timestamp).toLocaleTimeString('en-US'));
58
+ assert.equal(formatLogTime('not-a-timestamp', 'zh-CN'), 'not-a-timestamp');
59
+ });
60
+
61
  it('uses the no-image state in the preview header before generation', () => {
62
  const html = renderToStaticMarkup(
63
  <I18nProvider>
 
96
  assert.doesNotMatch(html, /workbench-sample/);
97
  assert.doesNotMatch(html, /灵感样张/);
98
  assert.doesNotMatch(html, /1024 x 768/);
99
+ for (const action of ['继续编辑', '按相同参数再次生成', '复用提示词', '对比', '下载']) {
100
  assert.match(
101
  html,
102
  new RegExp(
src/components/image-output.tsx CHANGED
@@ -11,6 +11,7 @@ import {
11
  DialogTitle
12
  } from '@/components/ui/dialog';
13
  import { useI18n } from '@/lib/i18n';
 
14
  import { buildLogScopeDiagnostics, filterLogsByScope, resolveLogClientRequestIds } from '@/lib/log-filter';
15
  import { cn } from '@/lib/utils';
16
  import {
@@ -92,10 +93,10 @@ export function buildImageActionTarget(image: ImageInfo | null): ImageActionTarg
92
  };
93
  }
94
 
95
- function formatLogTime(value: string): string {
96
  const date = new Date(value);
97
  if (Number.isNaN(date.getTime())) return value;
98
- return date.toLocaleTimeString();
99
  }
100
 
101
  function formatLogScopeValues(values: string[]): string {
@@ -168,7 +169,7 @@ export function ImageOutput({
168
  imageBatch,
169
  viewMode,
170
  onViewChange,
171
- altText = 'Generated image output',
172
  isLoading,
173
  onSendToEdit,
174
  onDownloadImage,
@@ -191,7 +192,8 @@ export function ImageOutput({
191
  logClientRequestIds = [],
192
  logFilenames = []
193
  }: ImageOutputProps) {
194
- const { t } = useI18n();
 
195
  const [isLogDialogOpen, setIsLogDialogOpen] = React.useState(false);
196
  const [logs, setLogs] = React.useState<LogEntry[]>([]);
197
  const [logConnectionState, setLogConnectionState] = React.useState<'idle' | 'connected' | 'error'>('idle');
@@ -585,7 +587,7 @@ export function ImageOutput({
585
  <div className='photo-paper relative aspect-[4/3] w-full max-w-[780px] p-3 2xl:max-w-[860px]'>
586
  <Image
587
  src={imageBatch[viewMode].path}
588
- alt={altText}
589
  fill
590
  sizes='(max-width: 768px) 92vw, (max-width: 1536px) 44vw, 860px'
591
  className='image-edge rounded-md object-contain'
@@ -710,7 +712,7 @@ export function ImageOutput({
710
  visibleLogs.map((entry) => (
711
  <div key={entry.id} className='border-border/50 border-b py-2 last:border-b-0'>
712
  <div className='flex flex-wrap items-center gap-2'>
713
- <span className='text-muted-foreground'>{formatLogTime(entry.at)}</span>
714
  <span
715
  className={cn(
716
  'rounded border px-1.5 py-0.5 uppercase',
@@ -722,7 +724,7 @@ export function ImageOutput({
722
  'border-blue-500/40 text-blue-700 dark:border-blue-300/30 dark:text-blue-100',
723
  entry.level === 'debug' && 'border-border text-muted-foreground'
724
  )}>
725
- {entry.level}
726
  </span>
727
  <span className='text-foreground break-all'>{entry.message}</span>
728
  </div>
 
11
  DialogTitle
12
  } from '@/components/ui/dialog';
13
  import { useI18n } from '@/lib/i18n';
14
+ import { getActivityLogLevelLabel } from '@/lib/image-display-labels';
15
  import { buildLogScopeDiagnostics, filterLogsByScope, resolveLogClientRequestIds } from '@/lib/log-filter';
16
  import { cn } from '@/lib/utils';
17
  import {
 
93
  };
94
  }
95
 
96
+ export function formatLogTime(value: string, locale: string): string {
97
  const date = new Date(value);
98
  if (Number.isNaN(date.getTime())) return value;
99
+ return date.toLocaleTimeString(locale);
100
  }
101
 
102
  function formatLogScopeValues(values: string[]): string {
 
169
  imageBatch,
170
  viewMode,
171
  onViewChange,
172
+ altText,
173
  isLoading,
174
  onSendToEdit,
175
  onDownloadImage,
 
192
  logClientRequestIds = [],
193
  logFilenames = []
194
  }: ImageOutputProps) {
195
+ const { locale, t } = useI18n();
196
+ const resolvedAltText = altText ?? t('output.alt');
197
  const [isLogDialogOpen, setIsLogDialogOpen] = React.useState(false);
198
  const [logs, setLogs] = React.useState<LogEntry[]>([]);
199
  const [logConnectionState, setLogConnectionState] = React.useState<'idle' | 'connected' | 'error'>('idle');
 
587
  <div className='photo-paper relative aspect-[4/3] w-full max-w-[780px] p-3 2xl:max-w-[860px]'>
588
  <Image
589
  src={imageBatch[viewMode].path}
590
+ alt={resolvedAltText}
591
  fill
592
  sizes='(max-width: 768px) 92vw, (max-width: 1536px) 44vw, 860px'
593
  className='image-edge rounded-md object-contain'
 
712
  visibleLogs.map((entry) => (
713
  <div key={entry.id} className='border-border/50 border-b py-2 last:border-b-0'>
714
  <div className='flex flex-wrap items-center gap-2'>
715
+ <span className='text-muted-foreground'>{formatLogTime(entry.at, locale)}</span>
716
  <span
717
  className={cn(
718
  'rounded border px-1.5 py-0.5 uppercase',
 
724
  'border-blue-500/40 text-blue-700 dark:border-blue-300/30 dark:text-blue-100',
725
  entry.level === 'debug' && 'border-border text-muted-foreground'
726
  )}>
727
+ {getActivityLogLevelLabel(entry.level, t)}
728
  </span>
729
  <span className='text-foreground break-all'>{entry.message}</span>
730
  </div>
src/components/language-selector.test.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LanguageSelector } from './language-selector';
2
+ import { I18nProvider, useI18n } from '@/lib/i18n';
3
+ import { renderInClientDom } from '@/test-utils/react-dom';
4
+ import assert from 'node:assert/strict';
5
+ import { readFile } from 'node:fs/promises';
6
+ import { describe, it } from 'node:test';
7
+ import * as React from 'react';
8
+ import { renderToStaticMarkup } from 'react-dom/server';
9
+
10
+ function LocaleMetadataProbe() {
11
+ const { setLocale, t } = useI18n();
12
+
13
+ return (
14
+ <>
15
+ <button type='button' data-switch-to-english onClick={() => setLocale('en-US')}>
16
+ {t('app.language')}
17
+ </button>
18
+ <button type='button' data-switch-to-chinese onClick={() => setLocale('zh-CN')}>
19
+ {t('meta.title')}
20
+ </button>
21
+ <output data-locale-metadata-title>{t('meta.title')}</output>
22
+ </>
23
+ );
24
+ }
25
+
26
+ describe('LanguageSelector', { concurrency: false }, () => {
27
+ it('renders the localized accessible selector control', () => {
28
+ const html = renderToStaticMarkup(
29
+ <I18nProvider>
30
+ <LanguageSelector />
31
+ </I18nProvider>
32
+ );
33
+
34
+ assert.match(html, /data-language-selector/);
35
+ assert.match(html, /aria-label="语言"/);
36
+ assert.match(html, /data-size="sm"/);
37
+ });
38
+
39
+ it('delegates supported select values to the locale setter', async () => {
40
+ const source = await readFile(new URL('./language-selector.tsx', import.meta.url), 'utf8');
41
+
42
+ assert.match(source, /function isLocale\(value: string\): value is Locale/);
43
+ assert.match(
44
+ source,
45
+ /onValueChange=\{\(nextLocale\) => \{\s*if \(isLocale\(nextLocale\)\) \{\s*setLocale\(nextLocale\);/
46
+ );
47
+ assert.match(source, /<SelectItem value='zh-CN'>\{t\('app\.languageChinese'\)\}<\/SelectItem>/);
48
+ assert.match(source, /<SelectItem value='en-US'>\{t\('app\.languageEnglish'\)\}<\/SelectItem>/);
49
+ });
50
+
51
+ it('synchronizes browser metadata after the locale provider changes', async () => {
52
+ const view = await renderInClientDom(
53
+ <I18nProvider>
54
+ <LocaleMetadataProbe />
55
+ </I18nProvider>
56
+ );
57
+
58
+ try {
59
+ const englishControl = view.container.querySelector<HTMLButtonElement>('[data-switch-to-english]');
60
+ assert.ok(englishControl, 'missing test locale control');
61
+ assert.equal(englishControl.textContent, '语言');
62
+ await view.click(englishControl);
63
+
64
+ assert.equal(window.localStorage.getItem('gptImagePlaygroundLocale'), 'en-US');
65
+ assert.equal(document.documentElement.lang, 'en-US');
66
+ assert.equal(document.title, 'Visual Journal | AI Image Workspace');
67
+ assert.equal(
68
+ document.querySelector<HTMLMetaElement>('meta[name="description"]')?.content,
69
+ 'Visual Journal is an AI image workspace for generating, editing, and managing images.'
70
+ );
71
+ assert.equal(
72
+ view.container.querySelector('[data-locale-metadata-title]')?.textContent,
73
+ 'Visual Journal | AI Image Workspace'
74
+ );
75
+
76
+ const chineseControl = view.container.querySelector<HTMLButtonElement>('[data-switch-to-chinese]');
77
+ assert.ok(chineseControl, 'missing test locale reset control');
78
+ await view.click(chineseControl);
79
+ } finally {
80
+ await view.cleanup();
81
+ }
82
+ });
83
+ });
src/components/language-selector.tsx ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
4
+ import { type Locale, useI18n } from '@/lib/i18n';
5
+ import { cn } from '@/lib/utils';
6
+
7
+ type LanguageSelectorProps = {
8
+ className?: string;
9
+ };
10
+
11
+ function isLocale(value: string): value is Locale {
12
+ return value === 'zh-CN' || value === 'en-US';
13
+ }
14
+
15
+ export function LanguageSelector({ className }: LanguageSelectorProps) {
16
+ const { locale, setLocale, t } = useI18n();
17
+
18
+ return (
19
+ <Select
20
+ value={locale}
21
+ onValueChange={(nextLocale) => {
22
+ if (isLocale(nextLocale)) {
23
+ setLocale(nextLocale);
24
+ }
25
+ }}>
26
+ <SelectTrigger
27
+ aria-label={t('app.language')}
28
+ data-language-selector
29
+ size='sm'
30
+ className={cn('bg-card/80 h-11 min-w-28 shrink-0 shadow-sm lg:h-9', className)}>
31
+ <SelectValue />
32
+ </SelectTrigger>
33
+ <SelectContent align='end'>
34
+ <SelectItem value='zh-CN'>{t('app.languageChinese')}</SelectItem>
35
+ <SelectItem value='en-US'>{t('app.languageEnglish')}</SelectItem>
36
+ </SelectContent>
37
+ </Select>
38
+ );
39
+ }
src/components/mode-toggle.test.tsx CHANGED
@@ -16,7 +16,7 @@ describe('ModeToggle responsive labels', { concurrency: false }, () => {
16
  </I18nProvider>
17
  );
18
 
19
- assert.match(html, /复用历史/);
20
  assert.match(html, /data-workbench-mode="generate"/);
21
  assert.match(html, /data-workbench-mode="edit"/);
22
  assert.match(html, /data-workbench-mode="batch"/);
 
16
  </I18nProvider>
17
  );
18
 
19
+ assert.match(html, /复用提示词/);
20
  assert.match(html, /data-workbench-mode="generate"/);
21
  assert.match(html, /data-workbench-mode="edit"/);
22
  assert.match(html, /data-workbench-mode="batch"/);
src/components/password-dialog.tsx CHANGED
@@ -22,13 +22,7 @@ interface PasswordDialogProps {
22
  description?: string;
23
  }
24
 
25
- export function PasswordDialog({
26
- isOpen,
27
- onOpenChange,
28
- onSave,
29
- title = 'Configure Access Code',
30
- description
31
- }: PasswordDialogProps) {
32
  const { t } = useI18n();
33
  const [currentPassword, setCurrentPassword] = React.useState('');
34
  const inputRef = React.useRef<HTMLInputElement>(null);
@@ -57,7 +51,7 @@ export function PasswordDialog({
57
  <Dialog open={isOpen} onOpenChange={handleDialogClose}>
58
  <DialogContent className='sm:max-w-[425px]'>
59
  <DialogHeader>
60
- <DialogTitle>{title}</DialogTitle>
61
  {description && <DialogDescription>{description}</DialogDescription>}
62
  </DialogHeader>
63
  <form onSubmit={handleSubmit} className='grid gap-4'>
 
22
  description?: string;
23
  }
24
 
25
+ export function PasswordDialog({ isOpen, onOpenChange, onSave, title, description }: PasswordDialogProps) {
 
 
 
 
 
 
26
  const { t } = useI18n();
27
  const [currentPassword, setCurrentPassword] = React.useState('');
28
  const inputRef = React.useRef<HTMLInputElement>(null);
 
51
  <Dialog open={isOpen} onOpenChange={handleDialogClose}>
52
  <DialogContent className='sm:max-w-[425px]'>
53
  <DialogHeader>
54
+ <DialogTitle>{title ?? t('password.configure')}</DialogTitle>
55
  {description && <DialogDescription>{description}</DialogDescription>}
56
  </DialogHeader>
57
  <form onSubmit={handleSubmit} className='grid gap-4'>
src/components/share-dialog.test.tsx CHANGED
@@ -63,7 +63,7 @@ describe('ShareDialog', () => {
63
  assert.equal(getShareExpiryMinutes('unexpected'), 1440);
64
  });
65
 
66
- it('keeps explicit never-expiring shares when selected', () => {
67
  assert.equal(getShareExpiryMinutes('none'), null);
68
  });
69
 
 
63
  assert.equal(getShareExpiryMinutes('unexpected'), 1440);
64
  });
65
 
66
+ it('allows an explicit share without an expiry when selected', () => {
67
  assert.equal(getShareExpiryMinutes('none'), null);
68
  });
69
 
src/components/workbench-pro-dock-panels.tsx CHANGED
@@ -6,8 +6,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
6
  import type { WorkbenchProDockProps, OutputFormat, Quality, SizePreset } from '@/components/workbench-pro-dock';
7
  import { WorkbenchProRoutePanel } from '@/components/workbench-pro-route-panel';
8
  import type { GptImageModel } from '@/lib/cost-utils';
9
- import type { Locale } from '@/lib/i18n';
10
  import { useI18n } from '@/lib/i18n';
 
11
  import type { ImageStreamMode } from '@/lib/image-upstream-strategy';
12
  import { getPresetDimensions } from '@/lib/size-utils';
13
  import { resolveStreamingBatchToggleState } from '@/lib/streaming-batch';
@@ -21,11 +21,6 @@ const outputFormatOptions: OutputFormat[] = ['png', 'jpeg', 'webp'];
21
  const qualityOptions: Quality[] = ['auto', 'low', 'medium', 'high'];
22
  const streamModeOptions: ImageStreamMode[] = ['auto', 'stream', 'non_stream'];
23
 
24
- function getOutputFormatLabel(format: OutputFormat): string {
25
- if (format === 'jpeg') return 'JPG';
26
- return format.toUpperCase();
27
- }
28
-
29
  function getQualityLabel(quality: Quality, t: Translation): string {
30
  if (quality === 'auto') return t('common.auto');
31
  if (quality === 'low') return t('common.low');
@@ -49,10 +44,10 @@ function getStreamingStrategyLabel(strategy: ProPanelProps['streamingStrategy'],
49
  return t('upstream.serverDefault');
50
  }
51
 
52
- function formatResolution(size: SizePreset, model: GptImageModel, locale: Locale): string {
53
  const resolution = getPresetDimensions(size, model);
54
  if (resolution) return resolution;
55
- if (size === 'custom') return locale === 'zh-CN' ? '自定义' : 'Custom';
56
  return '1024 px';
57
  }
58
 
@@ -78,8 +73,8 @@ export function WorkbenchEasySummary({
78
  streamingStrategy,
79
  defaultStreamingStrategy
80
  }: ProPanelProps) {
81
- const { locale, t } = useI18n();
82
- const resolution = formatResolution(size, model, locale);
83
  const effectiveStreamingStrategy =
84
  streamingStrategy === 'server-default' ? defaultStreamingStrategy : streamingStrategy;
85
  const parallelBatchVisible = resolveStreamingBatchToggleState({
@@ -100,7 +95,7 @@ export function WorkbenchEasySummary({
100
  : getStreamModeLabel(streamMode, t)}
101
  </span>
102
  </ReadonlyField>
103
- <ReadonlyField label={t('form.outputFormat')}>{getOutputFormatLabel(outputFormat)}</ReadonlyField>
104
  <ReadonlyField label={t('ux.resolution')}>{resolution}</ReadonlyField>
105
  </div>
106
  );
@@ -135,7 +130,7 @@ function OutputPanel({
135
  <SelectContent>
136
  {outputFormatOptions.map((format) => (
137
  <SelectItem key={format} value={format}>
138
- {getOutputFormatLabel(format)}
139
  </SelectItem>
140
  ))}
141
  </SelectContent>
@@ -297,8 +292,8 @@ export function WorkbenchProPanel({
297
  defaultTab,
298
  ...props
299
  }: ProPanelProps & { defaultTab: 'output' | 'model' | 'stream' | 'route' }) {
300
- const { locale, t } = useI18n();
301
- const resolution = formatResolution(props.size, props.model, locale);
302
  const strategyLabel = getStreamingStrategyLabel(props.streamingStrategy, t);
303
 
304
  return (
 
6
  import type { WorkbenchProDockProps, OutputFormat, Quality, SizePreset } from '@/components/workbench-pro-dock';
7
  import { WorkbenchProRoutePanel } from '@/components/workbench-pro-route-panel';
8
  import type { GptImageModel } from '@/lib/cost-utils';
 
9
  import { useI18n } from '@/lib/i18n';
10
+ import { getImageOutputFormatLabel } from '@/lib/image-display-labels';
11
  import type { ImageStreamMode } from '@/lib/image-upstream-strategy';
12
  import { getPresetDimensions } from '@/lib/size-utils';
13
  import { resolveStreamingBatchToggleState } from '@/lib/streaming-batch';
 
21
  const qualityOptions: Quality[] = ['auto', 'low', 'medium', 'high'];
22
  const streamModeOptions: ImageStreamMode[] = ['auto', 'stream', 'non_stream'];
23
 
 
 
 
 
 
24
  function getQualityLabel(quality: Quality, t: Translation): string {
25
  if (quality === 'auto') return t('common.auto');
26
  if (quality === 'low') return t('common.low');
 
44
  return t('upstream.serverDefault');
45
  }
46
 
47
+ function formatResolution(size: SizePreset, model: GptImageModel, t: Translation): string {
48
  const resolution = getPresetDimensions(size, model);
49
  if (resolution) return resolution;
50
+ if (size === 'custom') return t('common.custom');
51
  return '1024 px';
52
  }
53
 
 
73
  streamingStrategy,
74
  defaultStreamingStrategy
75
  }: ProPanelProps) {
76
+ const { t } = useI18n();
77
+ const resolution = formatResolution(size, model, t);
78
  const effectiveStreamingStrategy =
79
  streamingStrategy === 'server-default' ? defaultStreamingStrategy : streamingStrategy;
80
  const parallelBatchVisible = resolveStreamingBatchToggleState({
 
95
  : getStreamModeLabel(streamMode, t)}
96
  </span>
97
  </ReadonlyField>
98
+ <ReadonlyField label={t('form.outputFormat')}>{getImageOutputFormatLabel(outputFormat, t)}</ReadonlyField>
99
  <ReadonlyField label={t('ux.resolution')}>{resolution}</ReadonlyField>
100
  </div>
101
  );
 
130
  <SelectContent>
131
  {outputFormatOptions.map((format) => (
132
  <SelectItem key={format} value={format}>
133
+ {getImageOutputFormatLabel(format, t)}
134
  </SelectItem>
135
  ))}
136
  </SelectContent>
 
292
  defaultTab,
293
  ...props
294
  }: ProPanelProps & { defaultTab: 'output' | 'model' | 'stream' | 'route' }) {
295
+ const { t } = useI18n();
296
+ const resolution = formatResolution(props.size, props.model, t);
297
  const strategyLabel = getStreamingStrategyLabel(props.streamingStrategy, t);
298
 
299
  return (
src/components/workbench-pro-dock.test.tsx CHANGED
@@ -95,6 +95,39 @@ describe('WorkbenchProDock', () => {
95
  assert.doesNotMatch(html, /EXIF/);
96
  });
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  it('renders interactive professional groups without fake output switches', () => {
99
  const html = renderToStaticMarkup(
100
  <I18nProvider>
 
95
  assert.doesNotMatch(html, /EXIF/);
96
  });
97
 
98
+ it('uses the shared localized label for a custom resolution', () => {
99
+ const html = renderToStaticMarkup(
100
+ <I18nProvider>
101
+ <WorkbenchProDock
102
+ outputFormat='png'
103
+ onOutputFormatChange={setOutputFormat}
104
+ quality='high'
105
+ onQualityChange={setQuality}
106
+ model='gpt-image-2'
107
+ onModelChange={setModel}
108
+ size='custom'
109
+ streamMode='auto'
110
+ onStreamModeChange={setStreamMode}
111
+ allowStreamingBatch={false}
112
+ enableParallelBatch={false}
113
+ onEnableParallelBatchChange={setEnableParallelBatch}
114
+ parallelBatchTargetCount={1}
115
+ allowResponsesImageBackend
116
+ hasDefaultResponsesModel
117
+ imageBackend='server-default'
118
+ onImageBackendChange={setImageBackend}
119
+ streamingStrategy='server-default'
120
+ defaultStreamingStrategy='auto'
121
+ onStreamingStrategyChange={setStreamingStrategy}
122
+ responsesModel=''
123
+ onResponsesModelChange={setResponsesModel}
124
+ />
125
+ </I18nProvider>
126
+ );
127
+
128
+ assert.match(html, /自定义/);
129
+ });
130
+
131
  it('renders interactive professional groups without fake output switches', () => {
132
  const html = renderToStaticMarkup(
133
  <I18nProvider>
src/components/workbench-status-strip.test.tsx CHANGED
@@ -5,23 +5,23 @@ import { describe, it } from 'node:test';
5
  import { renderToStaticMarkup } from 'react-dom/server';
6
 
7
  describe('WorkbenchStatusStrip', () => {
8
- it('shows model, request route, streaming status, cost, and runtime state before generation', () => {
9
  const html = renderToStaticMarkup(
10
  <I18nProvider>
11
  <WorkbenchStatusStrip
12
  model='gpt-image-2'
13
  routeLabel='images-sse'
14
- streamStatus='流式可用'
15
- costLabel='预计 0.12 积分'
16
  />
17
  </I18nProvider>
18
  );
19
 
20
- assert.match(html, /运行时可用/);
21
  assert.match(html, /gpt-image-2/);
22
  assert.match(html, /images-sse/);
23
- assert.match(html, /流式可用/);
24
- assert.match(html, /预计 0\.12 积分/);
25
  });
26
 
27
  it('shows explicit parallel batch state when the user enabled it for the current request', () => {
@@ -30,9 +30,9 @@ describe('WorkbenchStatusStrip', () => {
30
  <WorkbenchStatusStrip
31
  model='gpt-image-2'
32
  routeLabel='默认线路'
33
- streamStatus='流式可用'
34
  parallelBatchEnabled
35
- costLabel='预计 0.24 积分'
36
  />
37
  </I18nProvider>
38
  );
@@ -46,8 +46,8 @@ describe('WorkbenchStatusStrip', () => {
46
  <WorkbenchStatusStrip
47
  model='gpt-image-2'
48
  routeLabel='默认线路'
49
- streamStatus='普通生成'
50
- costLabel='预计 0.12 积分'
51
  />
52
  </I18nProvider>
53
  );
@@ -64,8 +64,8 @@ describe('WorkbenchStatusStrip', () => {
64
  <WorkbenchStatusStrip
65
  model='gpt-image-2'
66
  routeLabel='服务器默认'
67
- streamStatus='普通生成'
68
- costLabel='预计 0.12 积分'
69
  runtimeHealthStatus='checking'
70
  />
71
  </I18nProvider>
@@ -75,8 +75,8 @@ describe('WorkbenchStatusStrip', () => {
75
  <WorkbenchStatusStrip
76
  model='gpt-image-2'
77
  routeLabel='服务器默认'
78
- streamStatus='普通生成'
79
- costLabel='预计 0.12 积分'
80
  runtimeHealthStatus='disconnected'
81
  />
82
  </I18nProvider>
@@ -86,8 +86,8 @@ describe('WorkbenchStatusStrip', () => {
86
  <WorkbenchStatusStrip
87
  model='gpt-image-2'
88
  routeLabel='服务器默认'
89
- streamStatus='流式可用'
90
- costLabel='预计 0.12 积分'
91
  runtimeHealthStatus='route-limited'
92
  />
93
  </I18nProvider>
@@ -97,17 +97,17 @@ describe('WorkbenchStatusStrip', () => {
97
  <WorkbenchStatusStrip
98
  model='gpt-image-2'
99
  routeLabel='使用自定义上游'
100
- streamStatus='普通生成'
101
- costLabel='预计 0.12 积分'
102
  runtimeHealthStatus='custom-override'
103
  />
104
  </I18nProvider>
105
  );
106
 
107
- assert.match(checkingHtml, /正在检查运行时/);
108
  assert.match(checkingHtml, /animate-spin/);
109
- assert.match(disconnectedHtml, /运行时未连接/);
110
- assert.match(routeLimitedHtml, /运行态受限/);
111
  assert.match(customOverrideHtml, /使用自定义上游/);
112
  });
113
 
@@ -117,8 +117,8 @@ describe('WorkbenchStatusStrip', () => {
117
  <WorkbenchStatusStrip
118
  model='gpt-image-2'
119
  routeLabel='服务器默认'
120
- streamStatus='流式可用'
121
- costLabel='预计 0.12 积分'
122
  channelRouting={{
123
  effectiveRequestModes: ['images-non-stream', 'images-sse'],
124
  requestModeHealth: [
@@ -150,13 +150,14 @@ describe('WorkbenchStatusStrip', () => {
150
 
151
  assert.match(html, /请求方式/);
152
  assert.match(html, /images-non-stream/);
153
- assert.match(html, /已验证/);
154
  assert.match(html, /responses-sse/);
155
  assert.match(html, /冷却中或待探测/);
156
  assert.match(html, /最近渠道失败/);
157
  assert.match(html, /HTTP 403/);
158
  assert.match(html, /responses-sse/);
159
  assert.match(html, /OPENAI_CHANNEL_N_REQUEST_MODES=images-non-stream/);
 
160
  });
161
 
162
  it('keeps request mode details constrained on narrow screens', () => {
@@ -165,8 +166,8 @@ describe('WorkbenchStatusStrip', () => {
165
  <WorkbenchStatusStrip
166
  model='gpt-image-2'
167
  routeLabel='服务器默认'
168
- streamStatus='流式可用'
169
- costLabel='预计 0.12 积分'
170
  channelRouting={{
171
  effectiveRequestModes: ['images-non-stream'],
172
  requestModeHealth: [
 
5
  import { renderToStaticMarkup } from 'react-dom/server';
6
 
7
  describe('WorkbenchStatusStrip', () => {
8
+ it('shows model, request route, streaming mode, requested image count, and runtime state before generation', () => {
9
  const html = renderToStaticMarkup(
10
  <I18nProvider>
11
  <WorkbenchStatusStrip
12
  model='gpt-image-2'
13
  routeLabel='images-sse'
14
+ streamStatus='自动'
15
+ requestSummaryLabel='请求 1 张图片'
16
  />
17
  </I18nProvider>
18
  );
19
 
20
+ assert.match(html, /当前请求路径已就绪/);
21
  assert.match(html, /gpt-image-2/);
22
  assert.match(html, /images-sse/);
23
+ assert.match(html, /自动/);
24
+ assert.match(html, /请求 1 张图片/);
25
  });
26
 
27
  it('shows explicit parallel batch state when the user enabled it for the current request', () => {
 
30
  <WorkbenchStatusStrip
31
  model='gpt-image-2'
32
  routeLabel='默认线路'
33
+ streamStatus='自动'
34
  parallelBatchEnabled
35
+ requestSummaryLabel='请求 2 张图片'
36
  />
37
  </I18nProvider>
38
  );
 
46
  <WorkbenchStatusStrip
47
  model='gpt-image-2'
48
  routeLabel='默认线路'
49
+ streamStatus='非流式'
50
+ requestSummaryLabel='请求 1 张图片'
51
  />
52
  </I18nProvider>
53
  );
 
64
  <WorkbenchStatusStrip
65
  model='gpt-image-2'
66
  routeLabel='服务器默认'
67
+ streamStatus='非流式'
68
+ requestSummaryLabel='请求 1 张图片'
69
  runtimeHealthStatus='checking'
70
  />
71
  </I18nProvider>
 
75
  <WorkbenchStatusStrip
76
  model='gpt-image-2'
77
  routeLabel='服务器默认'
78
+ streamStatus='非流式'
79
+ requestSummaryLabel='请求 1 张图片'
80
  runtimeHealthStatus='disconnected'
81
  />
82
  </I18nProvider>
 
86
  <WorkbenchStatusStrip
87
  model='gpt-image-2'
88
  routeLabel='服务器默认'
89
+ streamStatus='自动'
90
+ requestSummaryLabel='请求 1 张图片'
91
  runtimeHealthStatus='route-limited'
92
  />
93
  </I18nProvider>
 
97
  <WorkbenchStatusStrip
98
  model='gpt-image-2'
99
  routeLabel='使用自定义上游'
100
+ streamStatus='非流式'
101
+ requestSummaryLabel='请求 1 张图片'
102
  runtimeHealthStatus='custom-override'
103
  />
104
  </I18nProvider>
105
  );
106
 
107
+ assert.match(checkingHtml, /正在读取运行时状态/);
108
  assert.match(checkingHtml, /animate-spin/);
109
+ assert.match(disconnectedHtml, /未取得运行时状态/);
110
+ assert.match(routeLimitedHtml, /当前路由受限/);
111
  assert.match(customOverrideHtml, /使用自定义上游/);
112
  });
113
 
 
117
  <WorkbenchStatusStrip
118
  model='gpt-image-2'
119
  routeLabel='服务器默认'
120
+ streamStatus='自动'
121
+ requestSummaryLabel='请求 1 张图片'
122
  channelRouting={{
123
  effectiveRequestModes: ['images-non-stream', 'images-sse'],
124
  requestModeHealth: [
 
150
 
151
  assert.match(html, /请求方式/);
152
  assert.match(html, /images-non-stream/);
153
+ assert.match(html, /可用于请求/);
154
  assert.match(html, /responses-sse/);
155
  assert.match(html, /冷却中或待探测/);
156
  assert.match(html, /最近渠道失败/);
157
  assert.match(html, /HTTP 403/);
158
  assert.match(html, /responses-sse/);
159
  assert.match(html, /OPENAI_CHANNEL_N_REQUEST_MODES=images-non-stream/);
160
+ assert.match(html, /建议的环境变量/);
161
  });
162
 
163
  it('keeps request mode details constrained on narrow screens', () => {
 
166
  <WorkbenchStatusStrip
167
  model='gpt-image-2'
168
  routeLabel='服务器默认'
169
+ streamStatus='自动'
170
+ requestSummaryLabel='请求 1 张图片'
171
  channelRouting={{
172
  effectiveRequestModes: ['images-non-stream'],
173
  requestModeHealth: [
src/components/workbench-status-strip.tsx CHANGED
@@ -31,7 +31,7 @@ type WorkbenchStatusStripProps = {
31
  routeLabel: string;
32
  streamStatus: string;
33
  parallelBatchEnabled?: boolean;
34
- costLabel: string;
35
  runtimeHealthStatus?: RuntimeHealthStatus;
36
  channelRouting?: RuntimeChannelRoutingSummary | null;
37
  runtimeLastFailure?: RuntimeLastFailure | null;
@@ -43,7 +43,7 @@ export function WorkbenchStatusStrip({
43
  routeLabel,
44
  streamStatus,
45
  parallelBatchEnabled = false,
46
- costLabel,
47
  runtimeHealthStatus = 'runtime-ready',
48
  channelRouting,
49
  runtimeLastFailure,
@@ -190,7 +190,7 @@ export function WorkbenchStatusStrip({
190
  </span>
191
  )}
192
  <span className='border-primary/20 bg-primary/10 text-primary inline-flex min-h-6 shrink-0 items-center rounded-md border px-2 py-0.5 whitespace-nowrap sm:min-h-7 sm:px-2.5 sm:py-1'>
193
- {costLabel}
194
  </span>
195
  </div>
196
  );
 
31
  routeLabel: string;
32
  streamStatus: string;
33
  parallelBatchEnabled?: boolean;
34
+ requestSummaryLabel: string;
35
  runtimeHealthStatus?: RuntimeHealthStatus;
36
  channelRouting?: RuntimeChannelRoutingSummary | null;
37
  runtimeLastFailure?: RuntimeLastFailure | null;
 
43
  routeLabel,
44
  streamStatus,
45
  parallelBatchEnabled = false,
46
+ requestSummaryLabel,
47
  runtimeHealthStatus = 'runtime-ready',
48
  channelRouting,
49
  runtimeLastFailure,
 
190
  </span>
191
  )}
192
  <span className='border-primary/20 bg-primary/10 text-primary inline-flex min-h-6 shrink-0 items-center rounded-md border px-2 py-0.5 whitespace-nowrap sm:min-h-7 sm:px-2.5 sm:py-1'>
193
+ {requestSummaryLabel}
194
  </span>
195
  </div>
196
  );
src/lib/i18n-contract.test.ts ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { describe, it } from 'node:test';
5
+ import ts from 'typescript';
6
+
7
+ const repositoryRoot = path.resolve(import.meta.dirname, '..', '..');
8
+ const sourceRoot = path.join(repositoryRoot, 'src');
9
+ const catalogPath = path.join(sourceRoot, 'lib', 'i18n.tsx');
10
+ const webUiRoots = [path.join(sourceRoot, 'app'), path.join(sourceRoot, 'components')];
11
+ const localeNames = ['zh-CN', 'en-US'] as const;
12
+ const userVisibleAttributeNames = new Set([
13
+ 'alt',
14
+ 'aria-label',
15
+ 'aria-description',
16
+ 'aria-valuetext',
17
+ 'description',
18
+ 'label',
19
+ 'placeholder',
20
+ 'title'
21
+ ]);
22
+
23
+ type LocaleName = (typeof localeNames)[number];
24
+ type Catalogs = Record<LocaleName, Record<string, string>>;
25
+
26
+ describe('WebUI translation contract', () => {
27
+ it('keeps locale keys and interpolation placeholders aligned', async () => {
28
+ const catalogs = await readCatalogs();
29
+ const [defaultLocale, secondaryLocale] = localeNames;
30
+ const defaultKeys = Object.keys(catalogs[defaultLocale]).sort();
31
+ const secondaryKeys = Object.keys(catalogs[secondaryLocale]).sort();
32
+
33
+ assert.deepEqual(secondaryKeys, defaultKeys);
34
+ for (const key of defaultKeys) {
35
+ assert.deepEqual(
36
+ extractPlaceholders(catalogs[secondaryLocale][key]),
37
+ extractPlaceholders(catalogs[defaultLocale][key]),
38
+ `placeholder mismatch for ${key}`
39
+ );
40
+ }
41
+ });
42
+
43
+ it('resolves every static WebUI translation call in both locales', async () => {
44
+ const catalogs = await readCatalogs();
45
+ const translationCalls = await collectStaticTranslationCalls(sourceRoot);
46
+
47
+ for (const [key, references] of translationCalls) {
48
+ for (const locale of localeNames) {
49
+ assert.ok(
50
+ catalogs[locale][key],
51
+ `missing ${locale} translation for ${key} at ${references.join(', ')}`
52
+ );
53
+ }
54
+ }
55
+ });
56
+
57
+ it('updates browser metadata through localized catalog entries', async () => {
58
+ const [catalogs, source] = await Promise.all([readCatalogs(), readFile(catalogPath, 'utf8')]);
59
+
60
+ for (const locale of localeNames) {
61
+ assert.ok(catalogs[locale]['meta.title'], `missing ${locale} metadata title`);
62
+ assert.ok(catalogs[locale]['meta.description'], `missing ${locale} metadata description`);
63
+ }
64
+ assert.match(source, /const title = t\('meta\.title'\)/);
65
+ assert.match(source, /document\.title = title/);
66
+ assert.match(source, /const descriptionContent = t\('meta\.description'\)/);
67
+ assert.match(source, /description\.content = descriptionContent/);
68
+ assert.match(source, /new MutationObserver\(scheduleMetadataSync\)/);
69
+ assert.match(source, /metadataObserver\.observe\(document\.head/);
70
+ assert.match(source, /attributeFilter: \['content'\]/);
71
+ });
72
+
73
+ it('defines localized language names for every selectable locale', async () => {
74
+ const catalogs = await readCatalogs();
75
+
76
+ assert.equal(catalogs['zh-CN']['app.languageChinese'], '简体中文');
77
+ assert.equal(catalogs['zh-CN']['app.languageEnglish'], 'English');
78
+ assert.equal(catalogs['en-US']['app.languageChinese'], 'Simplified Chinese');
79
+ assert.equal(catalogs['en-US']['app.languageEnglish'], 'English');
80
+ });
81
+
82
+ it('does not hardcode natural-language WebUI text in JSX', async () => {
83
+ const violations = (await Promise.all(webUiRoots.map(collectUserVisibleTextViolations))).flat();
84
+
85
+ assert.deepEqual(violations, []);
86
+ });
87
+
88
+ it('uses concrete save-mask language for the persisted mask flow', async () => {
89
+ const catalogs = await readCatalogs();
90
+
91
+ assert.match(catalogs['zh-CN']['ux.disabledUnsavedMask'], /保存/);
92
+ assert.match(catalogs['zh-CN']['alert.saveMaskBeforeSubmit'], /保存/);
93
+ assert.equal(catalogs['zh-CN']['edit.saveMask'], '保存蒙版');
94
+ assert.match(catalogs['en-US']['ux.disabledUnsavedMask'], /^Save /);
95
+ assert.match(catalogs['en-US']['alert.saveMaskBeforeSubmit'], /^Save /);
96
+ assert.equal(catalogs['en-US']['edit.saveMask'], 'Save mask');
97
+ });
98
+
99
+ it('does not expose optional-plural notation in English WebUI copy', async () => {
100
+ const catalogs = await readCatalogs();
101
+ const optionalPluralValues = Object.values(catalogs['en-US']).filter((value) => /\b\w+\(s\)/.test(value));
102
+
103
+ assert.deepEqual(optionalPluralValues, []);
104
+ });
105
+ });
106
+
107
+ async function readCatalogs(): Promise<Catalogs> {
108
+ const source = await readFile(catalogPath, 'utf8');
109
+ const file = ts.createSourceFile(catalogPath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
110
+ const messagesDeclaration = file.statements
111
+ .filter(ts.isVariableStatement)
112
+ .flatMap((statement) => statement.declarationList.declarations)
113
+ .find((declaration) => ts.isIdentifier(declaration.name) && declaration.name.text === 'messages');
114
+
115
+ assert.ok(messagesDeclaration?.initializer && ts.isObjectLiteralExpression(messagesDeclaration.initializer));
116
+ const catalogs = {} as Catalogs;
117
+ for (const localeProperty of messagesDeclaration.initializer.properties) {
118
+ if (!ts.isPropertyAssignment(localeProperty)) continue;
119
+ const locale = getPropertyName(localeProperty.name);
120
+ if (!locale || !isLocaleName(locale) || !ts.isObjectLiteralExpression(localeProperty.initializer)) continue;
121
+ catalogs[locale] = readCatalog(localeProperty.initializer);
122
+ }
123
+
124
+ for (const locale of localeNames) {
125
+ assert.ok(catalogs[locale], `missing ${locale} catalog`);
126
+ }
127
+ return catalogs;
128
+ }
129
+
130
+ function readCatalog(object: ts.ObjectLiteralExpression): Record<string, string> {
131
+ const catalog: Record<string, string> = {};
132
+ for (const property of object.properties) {
133
+ if (!ts.isPropertyAssignment(property)) continue;
134
+ const key = getPropertyName(property.name);
135
+ if (!key || !ts.isStringLiteralLike(property.initializer)) continue;
136
+ catalog[key] = property.initializer.text;
137
+ }
138
+ return catalog;
139
+ }
140
+
141
+ async function collectStaticTranslationCalls(root: string): Promise<Map<string, string[]>> {
142
+ const files = await collectSourceFiles(root);
143
+ const calls = new Map<string, string[]>();
144
+ for (const filename of files) {
145
+ if (filename === catalogPath || filename.endsWith('.test.ts') || filename.endsWith('.test.tsx')) continue;
146
+ const source = await readFile(filename, 'utf8');
147
+ const file = ts.createSourceFile(
148
+ filename,
149
+ source,
150
+ ts.ScriptTarget.Latest,
151
+ true,
152
+ source.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
153
+ );
154
+ collectCallsFromNode(file, file, calls);
155
+ }
156
+ return calls;
157
+ }
158
+
159
+ async function collectUserVisibleTextViolations(root: string): Promise<string[]> {
160
+ const files = await collectSourceFiles(root);
161
+ const violations: string[] = [];
162
+ for (const filename of files) {
163
+ if (!filename.endsWith('.tsx') || filename.endsWith('.test.tsx')) continue;
164
+ const source = await readFile(filename, 'utf8');
165
+ const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
166
+ collectUserVisibleTextViolationsFromNode(file, file, violations);
167
+ }
168
+ return violations;
169
+ }
170
+
171
+ function collectUserVisibleTextViolationsFromNode(file: ts.SourceFile, node: ts.Node, violations: string[]): void {
172
+ if (ts.isJsxText(node)) {
173
+ reportVisibleTextViolation(file, node, node.text, violations);
174
+ }
175
+ if (ts.isJsxExpression(node) && node.expression && ts.isStringLiteralLike(node.expression)) {
176
+ reportVisibleTextViolation(file, node, node.expression.text, violations);
177
+ }
178
+ if (ts.isJsxAttribute(node) && userVisibleAttributeNames.has(node.name.text)) {
179
+ const value = getStaticJsxAttributeValue(node);
180
+ if (value !== undefined) reportVisibleTextViolation(file, node, value, violations);
181
+ }
182
+ ts.forEachChild(node, (child) => collectUserVisibleTextViolationsFromNode(file, child, violations));
183
+ }
184
+
185
+ function getStaticJsxAttributeValue(attribute: ts.JsxAttribute): string | undefined {
186
+ if (!attribute.initializer) return undefined;
187
+ if (ts.isStringLiteralLike(attribute.initializer)) return attribute.initializer.text;
188
+ if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression) {
189
+ return ts.isStringLiteralLike(attribute.initializer.expression)
190
+ ? attribute.initializer.expression.text
191
+ : undefined;
192
+ }
193
+ return undefined;
194
+ }
195
+
196
+ function reportVisibleTextViolation(file: ts.SourceFile, node: ts.Node, value: string, violations: string[]): void {
197
+ const text = value.replace(/\s+/g, ' ').trim();
198
+ if (!text || !/[A-Za-z\u4e00-\u9fff]/.test(text) || isAllowedTechnicalUiText(text)) return;
199
+ const line = file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1;
200
+ violations.push(`${path.relative(repositoryRoot, file.fileName)}:${line} ${JSON.stringify(text)}`);
201
+ }
202
+
203
+ function isAllowedTechnicalUiText(value: string): boolean {
204
+ return (
205
+ value === 'x' ||
206
+ value === 'sRGB' ||
207
+ value === 'sk-...' ||
208
+ value === 'https://api.openai.com/v1' ||
209
+ value === 'OPENAI_RESPONSES_API_MODEL' ||
210
+ /^OPENAI_[A-Z0-9_]+=$/.test(value) ||
211
+ /^gpt-image-[\w.-]+:?$/.test(value)
212
+ );
213
+ }
214
+
215
+ function collectCallsFromNode(file: ts.SourceFile, node: ts.Node, calls: Map<string, string[]>): void {
216
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 't') {
217
+ const [firstArgument] = node.arguments;
218
+ if (firstArgument && ts.isStringLiteralLike(firstArgument)) {
219
+ const references = calls.get(firstArgument.text) ?? [];
220
+ const line = file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1;
221
+ references.push(`${path.relative(repositoryRoot, file.fileName)}:${line}`);
222
+ calls.set(firstArgument.text, references);
223
+ }
224
+ }
225
+ ts.forEachChild(node, (child) => collectCallsFromNode(file, child, calls));
226
+ }
227
+
228
+ async function collectSourceFiles(directory: string): Promise<string[]> {
229
+ const entries = await import('node:fs/promises').then(({ readdir }) => readdir(directory, { withFileTypes: true }));
230
+ const files = await Promise.all(
231
+ entries.map(async (entry) => {
232
+ const filename = path.join(directory, entry.name);
233
+ if (entry.isDirectory()) return await collectSourceFiles(filename);
234
+ return /\.tsx?$/.test(entry.name) ? [filename] : [];
235
+ })
236
+ );
237
+ return files.flat();
238
+ }
239
+
240
+ function extractPlaceholders(value: string): string[] {
241
+ return [...value.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort();
242
+ }
243
+
244
+ function getPropertyName(name: ts.PropertyName): string | undefined {
245
+ return ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name) ? name.text : undefined;
246
+ }
247
+
248
+ function isLocaleName(value: string): value is LocaleName {
249
+ return localeNames.includes(value as LocaleName);
250
+ }
src/lib/i18n.tsx CHANGED
@@ -9,8 +9,12 @@ const defaultLocale: Locale = 'zh-CN';
9
 
10
  const messages: Record<Locale, Record<string, string>> = {
11
  'zh-CN': {
 
 
12
  'app.apiSettings': 'API 设置',
13
  'app.language': '语言',
 
 
14
  'app.theme': '主题',
15
  'app.themeLight': '亮色',
16
  'app.themeDark': '暗色',
@@ -20,32 +24,32 @@ const messages: Record<Locale, Record<string, string>> = {
20
  'app.skipToMainContent': '跳到主要内容',
21
  'app.creationControls': '创作参数',
22
  'app.canvasPreview': '画布预览',
23
- 'app.apiRuntimeChecking': '正在检查运行时',
24
- 'app.apiRuntimeReady': '运行时可用',
25
- 'app.apiRouteLimited': '运行态受限',
26
- 'app.apiDisconnected': '运行时未连接',
27
  'app.apiCustomOverride': '使用自定义上游',
28
  'app.requestModeHealth': '请求方式',
29
- 'app.requestModeVerified': '已验证',
30
  'app.requestModeNotConfigured': '未配置',
31
  'app.requestModeCoolingOrProbe': '冷却中或待探测',
32
  'app.recentChannelFailure': '最近渠道失败',
33
- 'app.suggestedRequestModes': '建议配置',
34
  'mode.generate': '文生图',
35
- 'mode.edit': '图生图',
36
  'mode.batch': '批量',
37
- 'mode.reuse': '复用历史',
38
  'mode.generateDescription': '从灵感开始',
39
- 'mode.editDescription': '继续改',
40
  'mode.batchDescription': '一次多张',
41
- 'mode.reuseDescription': '套用旧稿',
42
  'mode.batchPanelDescription': '用现有生成链路一次生成多张图,适合快速挑选方向。',
43
  'mode.reusePanelDescription': '从右侧灵感或最近生成套用提示词,再继续微调。',
44
  'mode.batchHint': '每行会作为一条独立任务生成一张图,生成后按批次进入最近生成。',
45
  'mode.reuseHint': '可以从右侧灵感相册或最近生成中套用提示词,套用后仍可自由修改。',
46
  'batch.promptList': '批量提示词列表',
47
  'batch.promptCount': '{count} 条任务',
48
- 'batch.promptPlaceholder': '每行输入一条真实提示词',
49
  'batch.summaryTitle': '任务摘要',
50
  'batch.summaryPerLine': '每一行会生成一张图,沿用下方尺寸、清晰度和格式。',
51
  'batch.summaryFailureTitle': '失败后处理',
@@ -55,6 +59,7 @@ const messages: Record<Locale, Record<string, string>> = {
55
  'batch.reuseFailed': '只保留失败项',
56
  'batch.pause': '暂停批量',
57
  'batch.pauseRequested': '暂停中',
 
58
  'batch.pauseHint': '已开始的任务会完成,未开始的任务会保留为失败项再重试。',
59
  'reuse.appliedTitle': '已套用内容',
60
  'reuse.editAppliedTitle': '已带入内容',
@@ -93,6 +98,7 @@ const messages: Record<Locale, Record<string, string>> = {
93
  'common.copied': '已复制',
94
  'common.error': '错误',
95
  'common.saved': '已保存',
 
96
  'common.enabled': '开启',
97
  'common.disabled': '关闭',
98
  'ux.advanced': '高级参数',
@@ -118,8 +124,8 @@ const messages: Record<Locale, Record<string, string>> = {
118
  'form.model': '模型',
119
  'form.selectModel': '选择模型',
120
  'form.prompt': '提示词',
121
- 'form.promptPlaceholder': '输入要生成的真实提示词',
122
- 'form.editPromptPlaceholder': '输入要修改、保留或增强的真实编辑要求',
123
  'form.numberOfImages': '数量:{count}',
124
  'form.batchNumberOfImages': '批量任务:{count}',
125
  'form.size': '尺寸',
@@ -132,7 +138,8 @@ const messages: Record<Locale, Record<string, string>> = {
132
  'form.moderation': '内容审核级别',
133
  'form.width': '宽度 (px)',
134
  'form.height': '高度 (px)',
135
- 'form.pixelsMeta': '{pixels} 像素(最大值的 {percent}%)- {ratio}',
 
136
  'form.ratio': '{ratio}:1 比例',
137
  'form.noRatio': '-',
138
  'form.customConstraints': '限制:宽度和高度必须是正整数;具体上游可能继续返回更严格的尺寸错误。',
@@ -148,8 +155,6 @@ const messages: Record<Locale, Record<string, string>> = {
148
  'streaming.modeAuto': '自动',
149
  'streaming.modeStream': '强制流式',
150
  'streaming.modeNonStream': '非流式',
151
- 'streaming.statusAvailable': '流式可用',
152
- 'streaming.statusStandard': '普通生成',
153
  'streaming.previewImages': '预览图数量',
154
  'streaming.disabledByCount': '只有生成单张图片(n=1)时支持流式预览。',
155
  'streaming.disabledByStrategy': '当前流式兼容模式已关闭。',
@@ -161,7 +166,7 @@ const messages: Record<Locale, Record<string, string>> = {
161
  'streaming.parallelBatchUnavailable': '并发批量需要流式模式;非流式会保持顺序执行。',
162
  'streaming.parallelBatchUnavailableCapacity': '当前服务端没有可用并发容量,会保持顺序执行。',
163
  'streaming.parallelBatchUnavailableSingle': '选择至少 2 张图片或 2 条提示词后可启用并发。',
164
- 'streaming.costHint': '每张预览图增加约 $0.003 成本(额外 100 个输出 token。',
165
  'streaming.highResolutionRecommendation':
166
  '当前是 high 质量大尺寸请求,建议启用流式预览或选择支持 keepalive SSE 的上游。',
167
  'upstream.backend': '图片生成后端',
@@ -184,7 +189,7 @@ const messages: Record<Locale, Record<string, string>> = {
184
  'upstream.backendImpactResponses':
185
  'Responses image_generation 需要实验开关和顶层模型,适合兼容测试,不建议作为默认路径。',
186
  'upstream.backendResponsesUnavailable':
187
- '当前运行时或默认服务器渠道未开放 Responses image_generation,页面会保持服务端默认或 Images API 路径。',
188
  'upstream.routeImpactMixedProfile':
189
  '当前服务端渠道包含不同上游模式,页面会按交集收窄数量、上传和预览能力,避免某个渠道收到不支持的参数。',
190
  'upstream.responsesRuntimeUnavailable':
@@ -198,9 +203,15 @@ const messages: Record<Locale, Record<string, string>> = {
198
  'upstream.routeImpactCost': '费用主要由模型、尺寸、数量和预览图数量决定;路由选择会影响可观测性和失败恢复。',
199
  'upstream.gptModel': 'GPT 顶层模型',
200
  'upstream.thinking': '思考强度',
 
 
 
 
 
 
201
  'upstream.promptOptimization': '提示词优化',
202
- 'upstream.forceWeb': '优先 Web 账号',
203
- 'upstream.forceWebHint': '仅在上游 mixed 分组和有效像素区间内生效;不会覆盖用户自接 API。',
204
  'generate.title': '写下灵感',
205
  'generate.description': '把画面感写清楚,再用基础参数控制成片方向。',
206
  'generate.submit': '生成图像',
@@ -209,12 +220,12 @@ const messages: Record<Locale, Record<string, string>> = {
209
  'edit.description': '使用文本提示词修改已有图片。',
210
  'edit.submit': '编辑图像',
211
  'edit.loading': '编辑中...',
212
- 'edit.sourceImages': '片 [最多{count}]',
213
  'edit.referenceTitle': '参考图',
214
  'edit.referenceHint': '最多 {count} 张,先放入想继续修改的图片。',
215
  'edit.referenceCount': '{count} / {max} 张',
216
  'edit.referenceAction': '放入参考图',
217
- 'edit.referenceEmpty': '支持 PNG、JPG、WebP,可多选。',
218
  'edit.referenceAddMore': '继续添加同一组参考图。',
219
  'edit.referenceReady': '参考图已放入,可继续补充。',
220
  'edit.referencePreparing': '正在整理参考图预览。',
@@ -227,17 +238,18 @@ const messages: Record<Locale, Record<string, string>> = {
227
  'edit.mask': '蒙版',
228
  'edit.createMask': '创建蒙版',
229
  'edit.closeMaskEditor': '关闭蒙版编辑器',
230
- 'edit.editSavedMask': '编辑已保存蒙版',
231
  'edit.drawMaskHint': '在下方图片上涂抹需要编辑的区域(涂抹区域会在蒙版中变为透明)。',
232
  'edit.brushSize': '画笔大小:{value}px',
233
  'edit.brushSizeLabel': '画笔大小',
234
  'edit.uploadMask': '上传蒙版',
235
  'edit.clearMask': '清除',
236
  'edit.saveMask': '保存蒙版',
237
- 'edit.maskPreview': '生成的蒙版预览:',
238
  'edit.maskPreviewLoading': '正在生成蒙版预览...',
239
- 'edit.maskSaved': '蒙版已保存。',
240
- 'edit.maskApplied': '已应用蒙版:{name}',
 
241
  'edit.imagePreviewForMasking': '用于绘制蒙版的图片预览',
242
  'edit.generatedMaskPreviewAlt': '生成的蒙版预览',
243
  'edit.sourcePreview': '源图片预览 {index}',
@@ -254,7 +266,7 @@ const messages: Record<Locale, Record<string, string>> = {
254
  'output.keepalive': '连接保持中...',
255
  'output.sendToEdit': '发送到编辑',
256
  'output.continueEdit': '继续编辑',
257
- 'output.createVariant': '做变体',
258
  'output.reusePrompt': '复用提示词',
259
  'output.compare': '对比',
260
  'output.compareReference': '上一张',
@@ -305,11 +317,19 @@ const messages: Record<Locale, Record<string, string>> = {
305
  'logs.status.idle': '未连接',
306
  'logs.status.connected': '实时连接中',
307
  'logs.status.error': '连接异常',
 
 
 
 
308
  'share.pageTitle': '图片分享',
309
  'share.loading': '正在加载分享信息...',
310
  'share.loadFailed': '加载分享失败。',
311
  'share.unlockFailed': '打开分享失败。',
 
312
  'share.expired': '这个分享已过期。',
 
 
 
313
  'share.unlock': '打开',
314
  'share.accessCodePlaceholder': '输入访问码',
315
  'share.accessCodeRequiredHint': '请输入访问码后再打开图片。',
@@ -321,7 +341,7 @@ const messages: Record<Locale, Record<string, string>> = {
321
  'share.accessCodeTooShort': '访问码至少需要 8 个字符。',
322
  'share.publicRiskHint': '无访问码分享时,链接获得者可以直接查看图片。建议保留有效期。',
323
  'share.expiry': '有效期',
324
- 'share.expiryNone': '永久有效',
325
  'share.expiryOneHour': '1 小时',
326
  'share.expiryOneDay': '1 天',
327
  'share.expirySevenDays': '7 天',
@@ -333,11 +353,11 @@ const messages: Record<Locale, Record<string, string>> = {
333
  'history.title': '灵感相册',
334
  'history.inspirationAlbum': '灵感相册',
335
  'history.recentGenerated': '最近生成',
336
- 'history.inspirationEmpty': '暂无已保存的灵感',
337
- 'history.applyInspiration': '套用',
338
- 'history.applyInspirationPrompt': '套用灵感:{title}',
339
  'history.applyHistory': '套用',
340
- 'history.saveToInspiration': '收藏',
341
  'history.reuseHistory': '复用',
342
  'history.continueEdit': '编辑',
343
  'history.saveHistoryPrompt': '收藏这条历史提示词',
@@ -353,13 +373,13 @@ const messages: Record<Locale, Record<string, string>> = {
353
  'history.resultFeedbackNoteEmpty': '未记录原因',
354
  'history.markResultUsable': '标记本次结果可用',
355
  'history.markResultNeedsRevision': '标记本次结果需要修改',
356
- 'history.deleteInspiration': '删除灵感',
357
  'history.favorite': '收藏',
358
- 'history.savedInspiration': '已保存的灵感',
359
  'history.template': '模板',
360
  'history.newInspirationTemplate': '新建灵感模板',
361
- 'history.applyFirstTemplate': '套用首个模板',
362
- 'history.inspirationCount': '{count} 条灵感',
363
  'history.manage': '管理',
364
  'history.generationStatus': '生成动态',
365
  'history.generationStatusHint': '请求、预览、保���和失败会在这里轻量更新。',
@@ -367,6 +387,7 @@ const messages: Record<Locale, Record<string, string>> = {
367
  'history.statusCreateDone': '新图已入册',
368
  'history.statusEditDone': '编辑已入册',
369
  'history.statusBatchSummary': '{count} 张,{duration}',
 
370
  'history.activityToneProgress': '进行',
371
  'history.activityToneSuccess': '完成',
372
  'history.activityToneWarning': '提醒',
@@ -393,14 +414,25 @@ const messages: Record<Locale, Record<string, string>> = {
393
  'history.totalImages': '生成图片总数:',
394
  'history.averageCost': '单张平均成本:',
395
  'history.totalEstimatedCost': '预计总成本:',
396
- 'history.actualCost': '实际扣费:',
397
- 'history.actualCostShort': '实扣',
398
  'history.estimatedCostShort': '估算',
399
- 'history.actualCostPending': '结算中',
400
  'history.actualCostUnavailable': '未匹配',
401
  'history.costSource': '扣费来源:',
402
  'history.costConfidence': '匹配置信度:',
403
- 'history.actualQuota': '实际 quota:',
 
 
 
 
 
 
 
 
 
 
 
404
  'history.matchedLogId': '日志 ID:',
405
  'history.costBreakdown': '成本明细',
406
  'history.costBreakdownDescription': '本次图片生成的预计成本明细。',
@@ -413,13 +445,17 @@ const messages: Record<Locale, Record<string, string>> = {
413
  'history.imageInputTokens': '图片输入 token:',
414
  'history.imageOutputTokens': '图片输出 token:',
415
  'history.time': '耗时:',
 
 
 
 
416
  'history.generatedAt': '生成时间:',
417
  'history.mode': '模式:',
418
  'history.model': '模型:',
419
  'history.resolution': '分辨率:',
420
- 'history.quality': '质量:',
421
  'history.bg': '背景:',
422
- 'history.mod': '审核:',
423
  'history.imageCount': '图片数量:',
424
  'history.outputFormat': '输出格式:',
425
  'history.storage': '存储:',
@@ -453,20 +489,20 @@ const messages: Record<Locale, Record<string, string>> = {
453
  'retention.selectHistory': '选择最近生成图片',
454
  'retention.exitSelection': '结束选择',
455
  'retention.selectImage': '选择图片',
456
- 'retention.preserve': '永久保存',
457
- 'retention.release': '取消保',
458
- 'retention.permanentlySaved': '已永久保存',
459
  'retention.selectedCount': '已选择 {count} 张',
460
- 'retention.hint': '选择文件系统图片后,可设置或取消自动清理保护。',
461
- 'retention.updateFailed': '更新永久存状态失败。',
462
- 'retention.partialUpdateFailed': '部分图片的永久存状态未更新。',
463
- 'retention.loadFailed': '读取永久状态失败。',
464
  'history.showCost': '查看成本明细',
465
  'history.showTotalCost': '查看估算总成本摘要',
466
  'history.modeCreate': '生成',
467
  'history.modeEdit': '编辑',
468
- 'history.storageFile': '文件',
469
- 'history.storageDb': '数据库',
470
  'history.generatedOn': '生成时间:{time}',
471
  'api.title': 'API 设置',
472
  'api.description': '留空时使用服务器环境变量;填写后仅保存在当前浏览器。',
@@ -492,8 +528,10 @@ const messages: Record<Locale, Record<string, string>> = {
492
  'error.authVerifyUnavailable': '无法刷新访问权限,请检查网络或稍后重试。',
493
  'error.imageAccessRefreshFailed': '无法刷新图片访问权限,请重新输入访问码后再试。',
494
  'error.unexpected': '发生未知错误。',
495
- 'error.responseBodyNull': '响应体为空。',
 
496
  'error.streaming': '流式生成出错。',
 
497
  'error.apiFailedWithAdvice': '{message}。建议:{advice}',
498
  'error.adviceAuth': '检查 API Key、访问码或渠道权限。',
499
  'error.adviceRateLimit': '请求被限流。请稍后重试,或降低并发和图片数量。',
@@ -513,23 +551,24 @@ const messages: Record<Locale, Record<string, string>> = {
513
  '结果反馈已写入本地历史,但同步到服务端失败;已加入补偿队列,本页会重试,刷新或重开后也会继续尝试。',
514
  'error.resultFeedbackDeleteSync':
515
  '本地历史已删除,但服务端反馈清理失败;已加入清理补偿队列,本页会重试,刷新或重开后也会继续尝试。',
516
- 'error.clearHistory': '清空历史失败:{message}',
517
  'error.maxEditImages': '编辑表单最多只能添加 {count} 张图片。',
518
  'error.imageNotFoundDb': '本地数据库中找不到图片 {filename}。',
519
- 'error.fetchImage': '获取图片失败{statusText}',
520
  'error.retrieveImage': '无法读取图片数据:{filename}。',
 
521
  'error.sendToEdit': '发送到编辑表单失败。',
522
  'error.deleteUnexpected': '删除时发生未知错误。',
523
- 'error.deletePartial': '部分图片未能删除,或其永久存状态未能完整清理。',
524
  'confirm.clearHistoryFs': '确定要清空全部图片历史吗?此操作无法撤销。',
525
  'confirm.clearHistoryIndexedDb':
526
  '确定要清空全部图片历史吗?IndexedDB 模式下也会永久删除已存储图片。此操作无法撤销。',
527
  'workbench.creationSheet': '创作单',
528
  'workbench.promptTitle': '写下灵感',
529
  'workbench.basicSettings': '基础设置',
530
- 'workbench.estimatedCost': '预计 {credits} 积分',
531
- 'workbench.saveInspiration': '存为灵感',
532
- 'workbench.randomInspiration': '随便来点',
533
  'workbench.album': '相册',
534
  'alert.editNoImage': '请至少选择一张要编辑的图片。',
535
  'alert.maxImages': '最多只能选择 {count} 张图片。',
@@ -540,8 +579,12 @@ const messages: Record<Locale, Record<string, string>> = {
540
  'alert.pasteMaxImages': '无法粘贴:已达到最多 {count} 张图片的限制。'
541
  },
542
  'en-US': {
 
 
543
  'app.apiSettings': 'API Settings',
544
  'app.language': 'Language',
 
 
545
  'app.theme': 'Theme',
546
  'app.themeLight': 'Light',
547
  'app.themeDark': 'Dark',
@@ -551,32 +594,32 @@ const messages: Record<Locale, Record<string, string>> = {
551
  'app.skipToMainContent': 'Skip to main content',
552
  'app.creationControls': 'Creation controls',
553
  'app.canvasPreview': 'Canvas preview',
554
- 'app.apiRuntimeChecking': 'Checking runtime',
555
- 'app.apiRuntimeReady': 'Runtime ready',
556
- 'app.apiRouteLimited': 'Route limited',
557
- 'app.apiDisconnected': 'Runtime disconnected',
558
  'app.apiCustomOverride': 'Custom upstream',
559
  'app.requestModeHealth': 'Request modes',
560
- 'app.requestModeVerified': 'Verified',
561
  'app.requestModeNotConfigured': 'Not configured',
562
  'app.requestModeCoolingOrProbe': 'Cooling or pending probe',
563
  'app.recentChannelFailure': 'Recent channel failure',
564
- 'app.suggestedRequestModes': 'Suggested env',
565
  'mode.generate': 'Text to image',
566
- 'mode.edit': 'Image edit',
567
  'mode.batch': 'Batch',
568
- 'mode.reuse': 'Reuse',
569
  'mode.generateDescription': 'Start with an idea',
570
- 'mode.editDescription': 'Continue from image',
571
  'mode.batchDescription': 'Multiple outputs',
572
- 'mode.reuseDescription': 'Reuse drafts',
573
  'mode.batchPanelDescription': 'Generate multiple images with the current prompt and settings.',
574
  'mode.reusePanelDescription': 'Apply prompts from inspirations or recent generations, then refine them.',
575
  'mode.batchHint': 'Each line runs as one image task, then the results enter Recent as a batch.',
576
  'mode.reuseHint': 'Apply an inspiration or recent prompt from the right panel, then edit it freely.',
577
  'batch.promptList': 'Batch prompt list',
578
  'batch.promptCount': '{count} tasks',
579
- 'batch.promptPlaceholder': 'Enter one real prompt per line',
580
  'batch.summaryTitle': 'Task summary',
581
  'batch.summaryPerLine': 'Each line creates one image with the size, quality, and format below.',
582
  'batch.summaryFailureTitle': 'After a failure',
@@ -584,11 +627,11 @@ const messages: Record<Locale, Record<string, string>> = {
584
  'Failure reasons stay in Generation status so you can edit the matching prompt and submit again.',
585
  'batch.summaryProgress':
586
  'Progress and failure summaries stay in the right-side status area, not over the main preview.',
587
- 'batch.failedReuseHint':
588
- 'The last batch left {count} unfinished task(s). Keep only those prompts before retrying.',
589
  'batch.reuseFailed': 'Keep failed only',
590
  'batch.pause': 'Pause batch',
591
  'batch.pauseRequested': 'Pausing',
 
592
  'batch.pauseHint': 'Started tasks will finish. Unstarted tasks stay as failed items for retry.',
593
  'reuse.appliedTitle': 'Applied content',
594
  'reuse.editAppliedTitle': 'Carried into edit',
@@ -627,6 +670,7 @@ const messages: Record<Locale, Record<string, string>> = {
627
  'common.copied': 'Copied',
628
  'common.error': 'Error',
629
  'common.saved': 'Saved',
 
630
  'common.enabled': 'On',
631
  'common.disabled': 'Off',
632
  'ux.advanced': 'Advanced',
@@ -652,8 +696,8 @@ const messages: Record<Locale, Record<string, string>> = {
652
  'form.model': 'Model',
653
  'form.selectModel': 'Select model',
654
  'form.prompt': 'Prompt',
655
- 'form.promptPlaceholder': 'Enter the real prompt to generate',
656
- 'form.editPromptPlaceholder': 'Enter real edit instructions for the selected image',
657
  'form.numberOfImages': 'Images: {count}',
658
  'form.batchNumberOfImages': 'Batch tasks: {count}',
659
  'form.size': 'Size',
@@ -666,7 +710,8 @@ const messages: Record<Locale, Record<string, string>> = {
666
  'form.moderation': 'Moderation Level',
667
  'form.width': 'Width (px)',
668
  'form.height': 'Height (px)',
669
- 'form.pixelsMeta': '{pixels} pixels ({percent}% of max) - {ratio}',
 
670
  'form.ratio': '{ratio}:1 ratio',
671
  'form.noRatio': '-',
672
  'form.customConstraints':
@@ -683,8 +728,6 @@ const messages: Record<Locale, Record<string, string>> = {
683
  'streaming.modeAuto': 'Auto',
684
  'streaming.modeStream': 'Force streaming',
685
  'streaming.modeNonStream': 'Non-streaming',
686
- 'streaming.statusAvailable': 'Streaming ready',
687
- 'streaming.statusStandard': 'Standard',
688
  'streaming.previewImages': 'Preview Images',
689
  'streaming.disabledByCount': 'Streaming is only supported when generating a single image (n=1).',
690
  'streaming.disabledByStrategy': 'The current streaming compatibility mode is off.',
@@ -699,7 +742,8 @@ const messages: Record<Locale, Record<string, string>> = {
699
  'streaming.parallelBatchUnavailableCapacity':
700
  'The server has no available parallel capacity, so requests stay sequential.',
701
  'streaming.parallelBatchUnavailableSingle': 'Choose at least 2 images or 2 prompts to enable parallel batch.',
702
- 'streaming.costHint': 'Each preview image adds about $0.003 to the cost (100 additional output tokens).',
 
703
  'streaming.highResolutionRecommendation':
704
  'This is a high quality large image request. Streaming preview or a keepalive SSE upstream is recommended.',
705
  'upstream.backend': 'Image backend',
@@ -715,7 +759,7 @@ const messages: Record<Locale, Record<string, string>> = {
715
  'upstream.serverDefault': 'Server default',
716
  'upstream.workbenchDefaultRoute': 'Default route',
717
  'upstream.responsesModel': 'Responses top-level model',
718
- 'upstream.compatibilityParams': 'Upstream compatibility params',
719
  'upstream.routeImpactTitle': 'Impact notes',
720
  'upstream.backendImpactServerDefault':
721
  'Server default follows the current deployment configuration. It fits most work, with stability decided by the server channel.',
@@ -724,7 +768,7 @@ const messages: Record<Locale, Record<string, string>> = {
724
  'upstream.backendImpactResponses':
725
  'Responses image_generation requires the experimental flag and a top-level model. Use it for compatibility tests, not as the default path.',
726
  'upstream.backendResponsesUnavailable':
727
- 'Responses image_generation is not enabled for the current runtime or default server channel, so the page stays on the server default or Images API path.',
728
  'upstream.routeImpactMixedProfile':
729
  'The server pool mixes upstream modes, so the page narrows count, upload, and preview capabilities to their shared safe range.',
730
  'upstream.responsesRuntimeUnavailable':
@@ -743,8 +787,14 @@ const messages: Record<Locale, Record<string, string>> = {
743
  'Cost is mainly decided by model, size, count, and preview image count; route choices affect observability and failure recovery.',
744
  'upstream.gptModel': 'GPT top-level model',
745
  'upstream.thinking': 'Thinking effort',
 
 
 
 
 
 
746
  'upstream.promptOptimization': 'Prompt optimization',
747
- 'upstream.forceWeb': 'Prefer Web account',
748
  'upstream.forceWebHint':
749
  'Only applies to upstream mixed groups and valid pixel ranges; it does not override user-provided APIs.',
750
  'generate.title': 'Write the idea',
@@ -755,12 +805,12 @@ const messages: Record<Locale, Record<string, string>> = {
755
  'edit.description': 'Modify an existing image with a text prompt.',
756
  'edit.submit': 'Edit image',
757
  'edit.loading': 'Editing...',
758
- 'edit.sourceImages': 'Source Image(s) [Max: {count}]',
759
  'edit.referenceTitle': 'Reference images',
760
  'edit.referenceHint': 'Up to {count} images. Add the image you want to keep working on first.',
761
  'edit.referenceCount': '{count} / {max} images',
762
  'edit.referenceAction': 'Add reference image',
763
- 'edit.referenceEmpty': 'PNG, JPG, and WebP are supported. Multiple images are allowed.',
764
  'edit.referenceAddMore': 'Add more images to the same reference set.',
765
  'edit.referenceReady': 'Reference image added. You can add more.',
766
  'edit.referencePreparing': 'Preparing reference preview.',
@@ -773,18 +823,19 @@ const messages: Record<Locale, Record<string, string>> = {
773
  'edit.mask': 'Mask',
774
  'edit.createMask': 'Create Mask',
775
  'edit.closeMaskEditor': 'Close Mask Editor',
776
- 'edit.editSavedMask': 'Edit Saved Mask',
777
  'edit.drawMaskHint':
778
  'Draw on the image below to mark areas for editing (drawn areas become transparent in the mask).',
779
  'edit.brushSize': 'Brush Size: {value}px',
780
  'edit.brushSizeLabel': 'Brush Size',
781
  'edit.uploadMask': 'Upload Mask',
782
  'edit.clearMask': 'Clear',
783
- 'edit.saveMask': 'Save Mask',
784
- 'edit.maskPreview': 'Generated Mask Preview:',
785
  'edit.maskPreviewLoading': 'Generating mask preview...',
786
- 'edit.maskSaved': 'Mask saved successfully!',
787
- 'edit.maskApplied': 'Mask applied: {name}',
 
788
  'edit.imagePreviewForMasking': 'Image preview for masking',
789
  'edit.generatedMaskPreviewAlt': 'Generated mask preview',
790
  'edit.sourcePreview': 'Source preview {index}',
@@ -802,7 +853,7 @@ const messages: Record<Locale, Record<string, string>> = {
802
  'output.keepalive': 'Connection kept alive...',
803
  'output.sendToEdit': 'Send to Edit',
804
  'output.continueEdit': 'Continue edit',
805
- 'output.createVariant': 'Variant',
806
  'output.reusePrompt': 'Reuse prompt',
807
  'output.compare': 'Compare',
808
  'output.compareReference': 'Previous',
@@ -856,11 +907,19 @@ const messages: Record<Locale, Record<string, string>> = {
856
  'logs.status.idle': 'Disconnected',
857
  'logs.status.connected': 'Live connection active',
858
  'logs.status.error': 'Connection error',
 
 
 
 
859
  'share.pageTitle': 'Shared Image',
860
  'share.loading': 'Loading share details...',
861
  'share.loadFailed': 'Failed to load share.',
862
  'share.unlockFailed': 'Failed to open share.',
 
863
  'share.expired': 'This share has expired.',
 
 
 
864
  'share.unlock': 'Open',
865
  'share.accessCodePlaceholder': 'Enter access code',
866
  'share.accessCodeRequiredHint': 'Enter the access code to open the image.',
@@ -873,7 +932,7 @@ const messages: Record<Locale, Record<string, string>> = {
873
  'share.publicRiskHint':
874
  'Without an access code, anyone with the link can view the image. Keep an expiry enabled.',
875
  'share.expiry': 'Expiry',
876
- 'share.expiryNone': 'Never expires',
877
  'share.expiryOneHour': '1 hour',
878
  'share.expiryOneDay': '1 day',
879
  'share.expirySevenDays': '7 days',
@@ -883,13 +942,13 @@ const messages: Record<Locale, Record<string, string>> = {
883
  'share.create': 'Create Share',
884
  'share.createFailed': 'Failed to create share.',
885
  'history.title': 'Inspiration album',
886
- 'history.inspirationAlbum': 'Inspirations',
887
  'history.recentGenerated': 'Recent',
888
- 'history.inspirationEmpty': 'No saved inspirations yet.',
889
- 'history.applyInspiration': 'Apply',
890
- 'history.applyInspirationPrompt': 'Apply inspiration: {title}',
891
  'history.applyHistory': 'Apply',
892
- 'history.saveToInspiration': 'Save',
893
  'history.reuseHistory': 'Reuse',
894
  'history.continueEdit': 'Edit',
895
  'history.saveHistoryPrompt': 'Save this history prompt as an inspiration',
@@ -905,20 +964,21 @@ const messages: Record<Locale, Record<string, string>> = {
905
  'history.resultFeedbackNoteEmpty': 'No reason recorded',
906
  'history.markResultUsable': 'Mark this result as usable',
907
  'history.markResultNeedsRevision': 'Mark this result as needing revision',
908
- 'history.deleteInspiration': 'Delete inspiration',
909
  'history.favorite': 'Favorite',
910
- 'history.savedInspiration': 'Saved inspiration',
911
  'history.template': 'Template',
912
  'history.newInspirationTemplate': 'New template',
913
- 'history.applyFirstTemplate': 'Apply first template',
914
- 'history.inspirationCount': '{count} inspiration(s)',
915
  'history.manage': 'Manage',
916
  'history.generationStatus': 'Generation status',
917
  'history.generationStatusHint': 'Requests, previews, saves, and failures update here quietly.',
918
  'history.statusDone': 'Generated',
919
  'history.statusCreateDone': 'New image saved',
920
  'history.statusEditDone': 'Edit saved',
921
- 'history.statusBatchSummary': '{count} image(s), {duration}',
 
922
  'history.activityToneProgress': 'Live',
923
  'history.activityToneSuccess': 'Done',
924
  'history.activityToneWarning': 'Note',
@@ -930,12 +990,12 @@ const messages: Record<Locale, Record<string, string>> = {
930
  'history.activityPreparingEdit': 'Preparing edit source',
931
  'history.activityPreparingEditDetail': 'Loading the current image before switching to the edit form.',
932
  'history.activityBatchProgress': 'Batch progress',
933
- 'history.activityBatchProgressDetail': 'Completed {completed}/{total} task(s).',
934
- 'history.activityBatchProgressWithFailures': 'Completed {completed}/{total} task(s), {failed} failed.',
935
  'history.activityStreaming': 'Streaming preview updated',
936
- 'history.activityStreamingDetail': 'Received {count} in-progress preview image(s).',
937
  'history.activitySaved': 'Images saved',
938
- 'history.activitySavedDetail': '{count} result image(s) are ready to edit or download.',
939
  'history.activityFailed': 'Generation failed',
940
  'history.activityFailedDetail': '{message} Recommendation: check API settings and retry, or switch channel.',
941
  'history.empty': 'No generated images yet.',
@@ -947,14 +1007,25 @@ const messages: Record<Locale, Record<string, string>> = {
947
  'history.totalImages': 'Total Images Generated:',
948
  'history.averageCost': 'Average Cost Per Image:',
949
  'history.totalEstimatedCost': 'Total Estimated Cost:',
950
- 'history.actualCost': 'Actual Cost:',
951
- 'history.actualCostShort': 'Actual',
952
  'history.estimatedCostShort': 'Estimated',
953
- 'history.actualCostPending': 'Settling',
954
  'history.actualCostUnavailable': 'Not matched',
955
  'history.costSource': 'Cost Source:',
956
  'history.costConfidence': 'Match Confidence:',
957
- 'history.actualQuota': 'Actual Quota:',
 
 
 
 
 
 
 
 
 
 
 
958
  'history.matchedLogId': 'Log ID:',
959
  'history.costBreakdown': 'Cost Breakdown',
960
  'history.costBreakdownDescription': 'Estimated cost breakdown for this image generation.',
@@ -967,13 +1038,17 @@ const messages: Record<Locale, Record<string, string>> = {
967
  'history.imageInputTokens': 'Image Input Tokens:',
968
  'history.imageOutputTokens': 'Image Output Tokens:',
969
  'history.time': 'Time:',
 
 
 
 
970
  'history.generatedAt': 'Generated At:',
971
  'history.mode': 'Mode:',
972
  'history.model': 'Model:',
973
  'history.resolution': 'Resolution:',
974
  'history.quality': 'Quality:',
975
- 'history.bg': 'BG:',
976
- 'history.mod': 'Mod:',
977
  'history.imageCount': 'Image Count:',
978
  'history.outputFormat': 'Output Format:',
979
  'history.storage': 'Storage:',
@@ -993,7 +1068,7 @@ const messages: Record<Locale, Record<string, string>> = {
993
  'history.deleteItem': 'Delete history item',
994
  'history.confirmDeletion': 'Confirm Deletion',
995
  'history.confirmDeletionDescription':
996
- 'Are you sure you want to delete this history entry? This will remove {count} image(s). This action cannot be undone.',
997
  'history.dontAskAgain': "Don't ask me again",
998
  'history.viewBatch': 'View image batch from {time}',
999
  'history.previewBatch': 'Preview for batch generated at {time}',
@@ -1008,11 +1083,12 @@ const messages: Record<Locale, Record<string, string>> = {
1008
  'retention.selectHistory': 'Select recent images',
1009
  'retention.exitSelection': 'Done',
1010
  'retention.selectImage': 'Select image',
1011
- 'retention.preserve': 'Keep',
1012
- 'retention.release': 'Remove keep',
1013
- 'retention.permanentlySaved': 'Kept from cleanup',
1014
  'retention.selectedCount': '{count} selected',
1015
- 'retention.hint': 'Select file-system images to keep them from automatic cleanup.',
 
1016
  'retention.updateFailed': 'Failed to update automatic-cleanup protection.',
1017
  'retention.partialUpdateFailed': 'Some image cleanup-protection settings were not updated.',
1018
  'retention.loadFailed': 'Failed to load automatic-cleanup protection.',
@@ -1020,8 +1096,8 @@ const messages: Record<Locale, Record<string, string>> = {
1020
  'history.showTotalCost': 'Show estimated cost summary',
1021
  'history.modeCreate': 'Create',
1022
  'history.modeEdit': 'Edit',
1023
- 'history.storageFile': 'file',
1024
- 'history.storageDb': 'db',
1025
  'history.generatedOn': 'Generated on: {time}',
1026
  'api.title': 'API Settings',
1027
  'api.description': 'Leave blank to use server environment variables. Saved only in this browser when filled.',
@@ -1050,8 +1126,10 @@ const messages: Record<Locale, Record<string, string>> = {
1050
  'error.authVerifyUnavailable': 'Could not refresh access. Check the network or try again later.',
1051
  'error.imageAccessRefreshFailed': 'Unable to refresh image access. Enter the access code again and retry.',
1052
  'error.unexpected': 'An unexpected error occurred.',
1053
- 'error.responseBodyNull': 'Response body is null',
 
1054
  'error.streaming': 'Streaming error occurred',
 
1055
  'error.apiFailedWithAdvice': '{message}. Recommendation: {advice}',
1056
  'error.adviceAuth': 'Check the API Key, access code, or channel permissions.',
1057
  'error.adviceRateLimit': 'The request was rate limited. Retry later, or lower concurrency and image count.',
@@ -1059,9 +1137,9 @@ const messages: Record<Locale, Record<string, string>> = {
1059
  'The upstream service or API relay is failing. Retry later, or switch to another channel.',
1060
  'error.adviceCloudflare':
1061
  '4K or high-resolution generation may exceed the Cloudflare 100-second limit. Lower the resolution, or use an API relay that supports 4K streaming output: {url}',
1062
- 'error.batchPartialFailure': 'Batch generation partially failed: {failed}/{total} job(s) failed.',
1063
  'error.batchPartialFailureDetailed':
1064
- 'Batch generation partially failed: {failed}/{total} job(s) failed. Details: {reasons}',
1065
  'error.apiFailed': 'API request failed with status {status}',
1066
  'error.imageMissingBase64': 'Image {filename} missing base64 data in IndexedDB mode.',
1067
  'error.apiOmittedPaths': 'API response omitted one or more image paths.',
@@ -1074,28 +1152,29 @@ const messages: Record<Locale, Record<string, string>> = {
1074
  'Result feedback was saved locally, but server sync failed. It was queued and will retry on this page, then continue after refresh or reopen.',
1075
  'error.resultFeedbackDeleteSync':
1076
  'Local history was deleted, but server feedback cleanup failed. Cleanup was queued and will retry on this page, then continue after refresh or reopen.',
1077
- 'error.clearHistory': 'Failed to clear history: {message}',
1078
  'error.maxEditImages': 'Cannot add more than {count} images to the edit form.',
1079
  'error.imageNotFoundDb': 'Image {filename} not found in local database.',
1080
- 'error.fetchImage': 'Failed to fetch image: {statusText}',
1081
  'error.retrieveImage': 'Could not retrieve image data for {filename}.',
 
1082
  'error.sendToEdit': 'Failed to send image to edit form.',
1083
  'error.deleteUnexpected': 'An unexpected error occurred during deletion.',
1084
  'error.deletePartial':
1085
- 'Some images could not be deleted, or their cleanup-protection state could not be fully cleared.',
1086
  'confirm.clearHistoryFs': 'Are you sure you want to clear the entire image history? This cannot be undone.',
1087
  'confirm.clearHistoryIndexedDb':
1088
  'Are you sure you want to clear the entire image history? In IndexedDB mode, this will also permanently delete all stored images. This cannot be undone.',
1089
  'workbench.creationSheet': 'Creation sheet',
1090
  'workbench.promptTitle': 'Write the idea',
1091
  'workbench.basicSettings': 'Basic settings',
1092
- 'workbench.estimatedCost': 'Est. {credits} credits',
1093
- 'workbench.saveInspiration': 'Save inspiration',
1094
- 'workbench.randomInspiration': 'Surprise me',
1095
  'workbench.album': 'Album',
1096
  'alert.editNoImage': 'Please select at least one image to edit.',
1097
  'alert.maxImages': 'You can only select up to {count} images.',
1098
- 'alert.saveMaskBeforeSubmit': 'Please save the mask you have drawn before submitting.',
1099
  'alert.maskInvalidType': 'Invalid file type. Please upload a PNG file for the mask.',
1100
  'alert.maskDimensionMismatch':
1101
  'Mask dimensions ({actual}) must match the source image dimensions ({expected}).',
@@ -1163,10 +1242,65 @@ function interpolate(template: string, values?: Record<string, string | number>)
1163
 
1164
  export function I18nProvider({ children }: { children: React.ReactNode }) {
1165
  const locale = React.useSyncExternalStore(subscribeToLocaleChanges, getLocaleSnapshot, () => defaultLocale);
 
 
 
 
 
 
 
1166
 
1167
  React.useEffect(() => {
1168
- document.documentElement.lang = locale;
1169
- }, [locale]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1170
 
1171
  React.useEffect(() => {
1172
  queueMicrotask(syncLocaleFromStorage);
@@ -1177,14 +1311,6 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
1177
  emitLocale(nextLocale);
1178
  }, []);
1179
 
1180
- const t = React.useCallback(
1181
- (key: string, values?: Record<string, string | number>) => {
1182
- const template = messages[locale][key] ?? messages[defaultLocale][key] ?? key;
1183
- return interpolate(template, values);
1184
- },
1185
- [locale]
1186
- );
1187
-
1188
  const value = React.useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
1189
 
1190
  return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
 
9
 
10
  const messages: Record<Locale, Record<string, string>> = {
11
  'zh-CN': {
12
+ 'meta.title': '图像手记 | AI 图像创作工作台',
13
+ 'meta.description': '图像手记是用于生成、编辑和管理图像的 AI 图像创作工作台。',
14
  'app.apiSettings': 'API 设置',
15
  'app.language': '语言',
16
+ 'app.languageChinese': '简体中文',
17
+ 'app.languageEnglish': 'English',
18
  'app.theme': '主题',
19
  'app.themeLight': '亮色',
20
  'app.themeDark': '暗色',
 
24
  'app.skipToMainContent': '跳到主要内容',
25
  'app.creationControls': '创作参数',
26
  'app.canvasPreview': '画布预览',
27
+ 'app.apiRuntimeChecking': '正在读取运行时状态',
28
+ 'app.apiRuntimeReady': '当前请求路径已就绪',
29
+ 'app.apiRouteLimited': '当前路由受限',
30
+ 'app.apiDisconnected': '未取得运行时状态',
31
  'app.apiCustomOverride': '使用自定义上游',
32
  'app.requestModeHealth': '请求方式',
33
+ 'app.requestModeVerified': '可用于请求',
34
  'app.requestModeNotConfigured': '未配置',
35
  'app.requestModeCoolingOrProbe': '冷却中或待探测',
36
  'app.recentChannelFailure': '最近渠道失败',
37
+ 'app.suggestedRequestModes': '建议的环境变量',
38
  'mode.generate': '文生图',
39
+ 'mode.edit': '图像编辑',
40
  'mode.batch': '批量',
41
+ 'mode.reuse': '复用提示词',
42
  'mode.generateDescription': '从灵感开始',
43
+ 'mode.editDescription': '基于参考编辑',
44
  'mode.batchDescription': '一次多张',
45
+ 'mode.reuseDescription': '套用已有提示词',
46
  'mode.batchPanelDescription': '用现有生成链路一次生成多张图,适合快速挑选方向。',
47
  'mode.reusePanelDescription': '从右侧灵感或最近生成套用提示词,再继续微调。',
48
  'mode.batchHint': '每行会作为一条独立任务生成一张图,生成后按批次进入最近生成。',
49
  'mode.reuseHint': '可以从右侧灵感相册或最近生成中套用提示词,套用后仍可自由修改。',
50
  'batch.promptList': '批量提示词列表',
51
  'batch.promptCount': '{count} 条任务',
52
+ 'batch.promptPlaceholder': '每行输入一条提示词',
53
  'batch.summaryTitle': '任务摘要',
54
  'batch.summaryPerLine': '每一行会生成一张图,沿用下方尺寸、清晰度和格式。',
55
  'batch.summaryFailureTitle': '失败后处理',
 
59
  'batch.reuseFailed': '只保留失败项',
60
  'batch.pause': '暂停批量',
61
  'batch.pauseRequested': '暂停中',
62
+ 'batch.pausedFailure': '任务在开始前已暂停。',
63
  'batch.pauseHint': '已开始的任务会完成,未开始的任务会保留为失败项再重试。',
64
  'reuse.appliedTitle': '已套用内容',
65
  'reuse.editAppliedTitle': '已带入内容',
 
98
  'common.copied': '已复制',
99
  'common.error': '错误',
100
  'common.saved': '已保存',
101
+ 'common.unavailable': '不可用',
102
  'common.enabled': '开启',
103
  'common.disabled': '关闭',
104
  'ux.advanced': '高级参数',
 
124
  'form.model': '模型',
125
  'form.selectModel': '选择模型',
126
  'form.prompt': '提示词',
127
+ 'form.promptPlaceholder': '描述想生成的画面',
128
+ 'form.editPromptPlaceholder': '描述要修改、保留或增强的内容',
129
  'form.numberOfImages': '数量:{count}',
130
  'form.batchNumberOfImages': '批量任务:{count}',
131
  'form.size': '尺寸',
 
138
  'form.moderation': '内容审核级别',
139
  'form.width': '宽度 (px)',
140
  'form.height': '高度 (px)',
141
+ 'form.pixelsMeta': '{pixels} 像素 - {ratio}',
142
+ 'form.pixelsMetaOfMaximum': '{pixels} 像素(最大值的 {percent}%)- {ratio}',
143
  'form.ratio': '{ratio}:1 比例',
144
  'form.noRatio': '-',
145
  'form.customConstraints': '限制:宽度和高度必须是正整数;具体上游可能继续返回更严格的尺寸错误。',
 
155
  'streaming.modeAuto': '自动',
156
  'streaming.modeStream': '强制流式',
157
  'streaming.modeNonStream': '非流式',
 
 
158
  'streaming.previewImages': '预览图数量',
159
  'streaming.disabledByCount': '只有生成单张图片(n=1)时支持流式预览。',
160
  'streaming.disabledByStrategy': '当前流式兼容模式已关闭。',
 
166
  'streaming.parallelBatchUnavailable': '并发批量需要流式模式;非流式会保持顺序执行。',
167
  'streaming.parallelBatchUnavailableCapacity': '当前服务端没有可用并发容量,会保持顺序执行。',
168
  'streaming.parallelBatchUnavailableSingle': '选择至少 2 张图片或 2 条提示词后可启用并发。',
169
+ 'streaming.costHint': '预览图可能增加输出 token 和费用,具体以当前上游计费为准。',
170
  'streaming.highResolutionRecommendation':
171
  '当前是 high 质量大尺寸请求,建议启用流式预览或选择支持 keepalive SSE 的上游。',
172
  'upstream.backend': '图片生成后端',
 
189
  'upstream.backendImpactResponses':
190
  'Responses image_generation 需要实验开关和顶层模型,适合兼容测试,不建议作为默认路径。',
191
  'upstream.backendResponsesUnavailable':
192
+ '当前运行时或默认服务器渠道未开放 Responses image_generation。请改用服务端默认或 Images API 路径。',
193
  'upstream.routeImpactMixedProfile':
194
  '当前服务端渠道包含不同上游模式,页面会按交集收窄数量、上传和预览能力,避免某个渠道收到不支持的参数。',
195
  'upstream.responsesRuntimeUnavailable':
 
203
  'upstream.routeImpactCost': '费用主要由模型、尺寸、数量和预览图数量决定;路由选择会影响可观测性和失败恢复。',
204
  'upstream.gptModel': 'GPT 顶层模型',
205
  'upstream.thinking': '思考强度',
206
+ 'upstream.thinkingNone': '无',
207
+ 'upstream.thinkingMinimal': '极低',
208
+ 'upstream.thinkingLow': '低',
209
+ 'upstream.thinkingMedium': '中',
210
+ 'upstream.thinkingHigh': '高',
211
+ 'upstream.thinkingXhigh': '极高',
212
  'upstream.promptOptimization': '提示词优化',
213
+ 'upstream.forceWeb': '优先使用 Web 账号',
214
+ 'upstream.forceWebHint': '仅在上游混合分组和有效像素区间内生效;不会覆盖用户自接 API。',
215
  'generate.title': '写下灵感',
216
  'generate.description': '把画面感写清楚,再用基础参数控制成片方向。',
217
  'generate.submit': '生成图像',
 
220
  'edit.description': '使用文本提示词修改已有图片。',
221
  'edit.submit': '编辑图像',
222
  'edit.loading': '编辑中...',
223
+ 'edit.sourceImages': '参考最多 {count} 张)',
224
  'edit.referenceTitle': '参考图',
225
  'edit.referenceHint': '最多 {count} 张,先放入想继续修改的图片。',
226
  'edit.referenceCount': '{count} / {max} 张',
227
  'edit.referenceAction': '放入参考图',
228
+ 'edit.referenceEmpty': '支持 PNG、JPEG、WebP,可多选。',
229
  'edit.referenceAddMore': '继续添加同一组参考图。',
230
  'edit.referenceReady': '参考图已放入,可继续补充。',
231
  'edit.referencePreparing': '正在整理参考图预览。',
 
238
  'edit.mask': '蒙版',
239
  'edit.createMask': '创建蒙版',
240
  'edit.closeMaskEditor': '关闭蒙版编辑器',
241
+ 'edit.editSavedMask': '编辑当前蒙版',
242
  'edit.drawMaskHint': '在下方图片上涂抹需要编辑的区域(涂抹区域会在蒙版中变为透明)。',
243
  'edit.brushSize': '画笔大小:{value}px',
244
  'edit.brushSizeLabel': '画笔大小',
245
  'edit.uploadMask': '上传蒙版',
246
  'edit.clearMask': '清除',
247
  'edit.saveMask': '保存蒙版',
248
+ 'edit.maskPreview': '当前蒙版预览:',
249
  'edit.maskPreviewLoading': '正在生成蒙版预览...',
250
+ 'edit.maskSaved': '蒙版已保存,可用于当前编辑。',
251
+ 'edit.maskAppliedStatus': '已保存',
252
+ 'edit.maskApplied': '当前蒙版:{name}',
253
  'edit.imagePreviewForMasking': '用于绘制蒙版的图片预览',
254
  'edit.generatedMaskPreviewAlt': '生成的蒙版预览',
255
  'edit.sourcePreview': '源图片预览 {index}',
 
266
  'output.keepalive': '连接保持中...',
267
  'output.sendToEdit': '发送到编辑',
268
  'output.continueEdit': '继续编辑',
269
+ 'output.createVariant': '按相同参数再次生成',
270
  'output.reusePrompt': '复用提示词',
271
  'output.compare': '对比',
272
  'output.compareReference': '上一张',
 
317
  'logs.status.idle': '未连接',
318
  'logs.status.connected': '实时连接中',
319
  'logs.status.error': '连接异常',
320
+ 'logs.levelDebug': '调试',
321
+ 'logs.levelInfo': '信息',
322
+ 'logs.levelWarn': '警告',
323
+ 'logs.levelError': '错误',
324
  'share.pageTitle': '图片分享',
325
  'share.loading': '正在加载分享信息...',
326
  'share.loadFailed': '加载分享失败。',
327
  'share.unlockFailed': '打开分享失败。',
328
+ 'share.notFound': '这个分享不存在或已被移除。',
329
  'share.expired': '这个分享已过期。',
330
+ 'share.accessCodeRequired': '这个分享需要访问码。',
331
+ 'share.accessCodeInvalid': '访问码不正确,请重试。',
332
+ 'share.accessCodeRateLimited': '访问码尝试次数过多,请稍后再试。',
333
  'share.unlock': '打开',
334
  'share.accessCodePlaceholder': '输入访问码',
335
  'share.accessCodeRequiredHint': '请输入访问码后再打开图片。',
 
341
  'share.accessCodeTooShort': '访问码至少需要 8 个字符。',
342
  'share.publicRiskHint': '无访问码分享时,链接获得者可以直接查看图片。建议保留有效期。',
343
  'share.expiry': '有效期',
344
+ 'share.expiryNone': '不设到期时间',
345
  'share.expiryOneHour': '1 小时',
346
  'share.expiryOneDay': '1 天',
347
  'share.expirySevenDays': '7 天',
 
353
  'history.title': '灵感相册',
354
  'history.inspirationAlbum': '灵感相册',
355
  'history.recentGenerated': '最近生成',
356
+ 'history.inspirationEmpty': '暂无已保存的提示词',
357
+ 'history.applyInspiration': '套用提示词',
358
+ 'history.applyInspirationPrompt': '套用提示词:{title}',
359
  'history.applyHistory': '套用',
360
+ 'history.saveToInspiration': '收藏提示词',
361
  'history.reuseHistory': '复用',
362
  'history.continueEdit': '编辑',
363
  'history.saveHistoryPrompt': '收藏这条历史提示词',
 
373
  'history.resultFeedbackNoteEmpty': '未记录原因',
374
  'history.markResultUsable': '标记本次结果可用',
375
  'history.markResultNeedsRevision': '标记本次结果需要修改',
376
+ 'history.deleteInspiration': '删除提示词',
377
  'history.favorite': '收藏',
378
+ 'history.savedInspiration': '已保存的提示词',
379
  'history.template': '模板',
380
  'history.newInspirationTemplate': '新建灵感模板',
381
+ 'history.applyFirstTemplate': '套用首条提示词',
382
+ 'history.inspirationCount': '已保存 {count} 条提示词',
383
  'history.manage': '管理',
384
  'history.generationStatus': '生成动态',
385
  'history.generationStatusHint': '请求、预览、保���和失败会在这里轻量更新。',
 
387
  'history.statusCreateDone': '新图已入册',
388
  'history.statusEditDone': '编辑已入册',
389
  'history.statusBatchSummary': '{count} 张,{duration}',
390
+ 'history.statusFailedSummary': '{status},耗时 {duration}',
391
  'history.activityToneProgress': '进行',
392
  'history.activityToneSuccess': '完成',
393
  'history.activityToneWarning': '提醒',
 
414
  'history.totalImages': '生成图片总数:',
415
  'history.averageCost': '单张平均成本:',
416
  'history.totalEstimatedCost': '预计总成本:',
417
+ 'history.actualCost': '匹配的上游用(美元等值):',
418
+ 'history.actualCostShort': '匹配费用',
419
  'history.estimatedCostShort': '估算',
420
+ 'history.actualCostPending': '等待匹配',
421
  'history.actualCostUnavailable': '未匹配',
422
  'history.costSource': '扣费来源:',
423
  'history.costConfidence': '匹配置信度:',
424
+ 'history.costSourceMatchedLog': '已匹配上游扣费日志',
425
+ 'history.costSourcePending': '等待上游扣费记录',
426
+ 'history.costSourceUnavailable': '未匹配到扣费记录',
427
+ 'history.costSourceEstimate': '本地估算',
428
+ 'history.costConfidenceExact': '完全匹配',
429
+ 'history.costConfidenceHigh': '高',
430
+ 'history.costConfidenceLow': '低',
431
+ 'history.costConfidenceNone': '无',
432
+ 'history.costReasonPending': '正在等待上游扣费记录。',
433
+ 'history.costReasonUnavailable': '未能获取可用的上游扣费记录。',
434
+ 'history.costReasonLowConfidence': '上游扣费记录无法唯一确认。',
435
+ 'history.actualQuota': '上游返回的额度值:',
436
  'history.matchedLogId': '日志 ID:',
437
  'history.costBreakdown': '成本明细',
438
  'history.costBreakdownDescription': '本次图片生成的预计成本明细。',
 
445
  'history.imageInputTokens': '图片输入 token:',
446
  'history.imageOutputTokens': '图片输出 token:',
447
  'history.time': '耗时:',
448
+ 'history.durationMilliseconds': '{value} 毫秒',
449
+ 'history.durationSeconds': '{value} 秒',
450
+ 'history.durationMinutesSeconds': '{minutes} 分 {seconds} 秒',
451
+ 'history.durationHoursMinutesSeconds': '{hours} 小时 {minutes} 分 {seconds} 秒',
452
  'history.generatedAt': '生成时间:',
453
  'history.mode': '模式:',
454
  'history.model': '模型:',
455
  'history.resolution': '分辨率:',
456
+ 'history.quality': '清晰度:',
457
  'history.bg': '背景:',
458
+ 'history.mod': '审核级别:',
459
  'history.imageCount': '图片数量:',
460
  'history.outputFormat': '输出格式:',
461
  'history.storage': '存储:',
 
489
  'retention.selectHistory': '选择最近生成图片',
490
  'retention.exitSelection': '结束选择',
491
  'retention.selectImage': '选择图片',
492
+ 'retention.preserve': '设为免于自动清理',
493
+ 'retention.release': '取消自动清理',
494
+ 'retention.permanentlySaved': '已免于自动清理',
495
  'retention.selectedCount': '已选择 {count} 张',
496
+ 'retention.hint': '仅防止服务端自动清理;手动删除或清空历史仍会移除图片。',
497
+ 'retention.updateFailed': '更新自动清理失败。',
498
+ 'retention.partialUpdateFailed': '部分图片的自动清理未更新。',
499
+ 'retention.loadFailed': '读取自动清理状态失败。',
500
  'history.showCost': '查看成本明细',
501
  'history.showTotalCost': '查看估算总成本摘要',
502
  'history.modeCreate': '生成',
503
  'history.modeEdit': '编辑',
504
+ 'history.storageFile': '服务器文件存储',
505
+ 'history.storageDb': '浏览器本地数据库',
506
  'history.generatedOn': '生成时间:{time}',
507
  'api.title': 'API 设置',
508
  'api.description': '留空时使用服务器环境变量;填写后仅保存在当前浏览器。',
 
528
  'error.authVerifyUnavailable': '无法刷新访问权限,请检查网络或稍后重试。',
529
  'error.imageAccessRefreshFailed': '无法刷新图片访问权限,请重新输入访问码后再试。',
530
  'error.unexpected': '发生未知错误。',
531
+ 'error.responseBodyNull': '服务返回了空响应,请重试。',
532
+ 'error.invalidRequest': '请求无效,请检查输入、图片和尺寸后重试。',
533
  'error.streaming': '流式生成出错。',
534
+ 'error.networkRequest': '无法连接图片服务,请检查网络后重试。',
535
  'error.apiFailedWithAdvice': '{message}。建议:{advice}',
536
  'error.adviceAuth': '检查 API Key、访问码或渠道权限。',
537
  'error.adviceRateLimit': '请求被限流。请稍后重试,或降低并发和图片数量。',
 
551
  '结果反馈已写入本地历史,但同步到服务端失败;已加入补偿队列,本页会重试,刷新或重开后也会继续尝试。',
552
  'error.resultFeedbackDeleteSync':
553
  '本地历史已删除,但服务端反馈清理失败;已加入清理补偿队列,本页会重试,刷新或重开后也会继续尝试。',
554
+ 'error.clearHistory': '清空历史失败,请重试。',
555
  'error.maxEditImages': '编辑表单最多只能添加 {count} 张图片。',
556
  'error.imageNotFoundDb': '本地数据库中找不到图片 {filename}。',
557
+ 'error.fetchImage': '获取图片失败(HTTP {status})。',
558
  'error.retrieveImage': '无法读取图片数据:{filename}。',
559
+ 'error.downloadHistory': '打包下载历史图片失败,请重试。',
560
  'error.sendToEdit': '发送到编辑表单失败。',
561
  'error.deleteUnexpected': '删除时发生未知错误。',
562
+ 'error.deletePartial': '部分图片未能删除,或其自动清理未能完整清理。',
563
  'confirm.clearHistoryFs': '确定要清空全部图片历史吗?此操作无法撤销。',
564
  'confirm.clearHistoryIndexedDb':
565
  '确定要清空全部图片历史吗?IndexedDB 模式下也会永久删除已存储图片。此操作无法撤销。',
566
  'workbench.creationSheet': '创作单',
567
  'workbench.promptTitle': '写下灵感',
568
  'workbench.basicSettings': '基础设置',
569
+ 'workbench.requestSummary': '请求 {count} 张图片',
570
+ 'workbench.saveInspiration': '收藏提示词',
571
+ 'workbench.randomInspiration': '随机套用',
572
  'workbench.album': '相册',
573
  'alert.editNoImage': '请至少选择一张要编辑的图片。',
574
  'alert.maxImages': '最多只能选择 {count} 张图片。',
 
579
  'alert.pasteMaxImages': '无法粘贴:已达到最多 {count} 张图片的限制。'
580
  },
581
  'en-US': {
582
+ 'meta.title': 'Visual Journal | AI Image Workspace',
583
+ 'meta.description': 'Visual Journal is an AI image workspace for generating, editing, and managing images.',
584
  'app.apiSettings': 'API Settings',
585
  'app.language': 'Language',
586
+ 'app.languageChinese': 'Simplified Chinese',
587
+ 'app.languageEnglish': 'English',
588
  'app.theme': 'Theme',
589
  'app.themeLight': 'Light',
590
  'app.themeDark': 'Dark',
 
594
  'app.skipToMainContent': 'Skip to main content',
595
  'app.creationControls': 'Creation controls',
596
  'app.canvasPreview': 'Canvas preview',
597
+ 'app.apiRuntimeChecking': 'Reading runtime status',
598
+ 'app.apiRuntimeReady': 'Request route ready',
599
+ 'app.apiRouteLimited': 'Current route limited',
600
+ 'app.apiDisconnected': 'Runtime status unavailable',
601
  'app.apiCustomOverride': 'Custom upstream',
602
  'app.requestModeHealth': 'Request modes',
603
+ 'app.requestModeVerified': 'Ready for requests',
604
  'app.requestModeNotConfigured': 'Not configured',
605
  'app.requestModeCoolingOrProbe': 'Cooling or pending probe',
606
  'app.recentChannelFailure': 'Recent channel failure',
607
+ 'app.suggestedRequestModes': 'Suggested environment variable',
608
  'mode.generate': 'Text to image',
609
+ 'mode.edit': 'Image editing',
610
  'mode.batch': 'Batch',
611
+ 'mode.reuse': 'Reuse prompt',
612
  'mode.generateDescription': 'Start with an idea',
613
+ 'mode.editDescription': 'Edit from a reference image',
614
  'mode.batchDescription': 'Multiple outputs',
615
+ 'mode.reuseDescription': 'Reuse a saved prompt',
616
  'mode.batchPanelDescription': 'Generate multiple images with the current prompt and settings.',
617
  'mode.reusePanelDescription': 'Apply prompts from inspirations or recent generations, then refine them.',
618
  'mode.batchHint': 'Each line runs as one image task, then the results enter Recent as a batch.',
619
  'mode.reuseHint': 'Apply an inspiration or recent prompt from the right panel, then edit it freely.',
620
  'batch.promptList': 'Batch prompt list',
621
  'batch.promptCount': '{count} tasks',
622
+ 'batch.promptPlaceholder': 'Enter one prompt per line',
623
  'batch.summaryTitle': 'Task summary',
624
  'batch.summaryPerLine': 'Each line creates one image with the size, quality, and format below.',
625
  'batch.summaryFailureTitle': 'After a failure',
 
627
  'Failure reasons stay in Generation status so you can edit the matching prompt and submit again.',
628
  'batch.summaryProgress':
629
  'Progress and failure summaries stay in the right-side status area, not over the main preview.',
630
+ 'batch.failedReuseHint': 'Unfinished prompts from the last batch: {count}. Keep only these before retrying.',
 
631
  'batch.reuseFailed': 'Keep failed only',
632
  'batch.pause': 'Pause batch',
633
  'batch.pauseRequested': 'Pausing',
634
+ 'batch.pausedFailure': 'The task was paused before it started.',
635
  'batch.pauseHint': 'Started tasks will finish. Unstarted tasks stay as failed items for retry.',
636
  'reuse.appliedTitle': 'Applied content',
637
  'reuse.editAppliedTitle': 'Carried into edit',
 
670
  'common.copied': 'Copied',
671
  'common.error': 'Error',
672
  'common.saved': 'Saved',
673
+ 'common.unavailable': 'Unavailable',
674
  'common.enabled': 'On',
675
  'common.disabled': 'Off',
676
  'ux.advanced': 'Advanced',
 
696
  'form.model': 'Model',
697
  'form.selectModel': 'Select model',
698
  'form.prompt': 'Prompt',
699
+ 'form.promptPlaceholder': 'Describe the image you want to create',
700
+ 'form.editPromptPlaceholder': 'Describe what to change, preserve, or enhance',
701
  'form.numberOfImages': 'Images: {count}',
702
  'form.batchNumberOfImages': 'Batch tasks: {count}',
703
  'form.size': 'Size',
 
710
  'form.moderation': 'Moderation Level',
711
  'form.width': 'Width (px)',
712
  'form.height': 'Height (px)',
713
+ 'form.pixelsMeta': '{pixels} pixels - {ratio}',
714
+ 'form.pixelsMetaOfMaximum': '{pixels} pixels ({percent}% of the maximum) - {ratio}',
715
  'form.ratio': '{ratio}:1 ratio',
716
  'form.noRatio': '-',
717
  'form.customConstraints':
 
728
  'streaming.modeAuto': 'Auto',
729
  'streaming.modeStream': 'Force streaming',
730
  'streaming.modeNonStream': 'Non-streaming',
 
 
731
  'streaming.previewImages': 'Preview Images',
732
  'streaming.disabledByCount': 'Streaming is only supported when generating a single image (n=1).',
733
  'streaming.disabledByStrategy': 'The current streaming compatibility mode is off.',
 
742
  'streaming.parallelBatchUnavailableCapacity':
743
  'The server has no available parallel capacity, so requests stay sequential.',
744
  'streaming.parallelBatchUnavailableSingle': 'Choose at least 2 images or 2 prompts to enable parallel batch.',
745
+ 'streaming.costHint':
746
+ 'Preview images may increase output tokens and cost. Refer to the current upstream pricing.',
747
  'streaming.highResolutionRecommendation':
748
  'This is a high quality large image request. Streaming preview or a keepalive SSE upstream is recommended.',
749
  'upstream.backend': 'Image backend',
 
759
  'upstream.serverDefault': 'Server default',
760
  'upstream.workbenchDefaultRoute': 'Default route',
761
  'upstream.responsesModel': 'Responses top-level model',
762
+ 'upstream.compatibilityParams': 'Upstream compatibility parameters',
763
  'upstream.routeImpactTitle': 'Impact notes',
764
  'upstream.backendImpactServerDefault':
765
  'Server default follows the current deployment configuration. It fits most work, with stability decided by the server channel.',
 
768
  'upstream.backendImpactResponses':
769
  'Responses image_generation requires the experimental flag and a top-level model. Use it for compatibility tests, not as the default path.',
770
  'upstream.backendResponsesUnavailable':
771
+ 'Responses image_generation is not enabled for the current runtime or default server channel. Choose Server default or Images API instead.',
772
  'upstream.routeImpactMixedProfile':
773
  'The server pool mixes upstream modes, so the page narrows count, upload, and preview capabilities to their shared safe range.',
774
  'upstream.responsesRuntimeUnavailable':
 
787
  'Cost is mainly decided by model, size, count, and preview image count; route choices affect observability and failure recovery.',
788
  'upstream.gptModel': 'GPT top-level model',
789
  'upstream.thinking': 'Thinking effort',
790
+ 'upstream.thinkingNone': 'None',
791
+ 'upstream.thinkingMinimal': 'Minimal',
792
+ 'upstream.thinkingLow': 'Low',
793
+ 'upstream.thinkingMedium': 'Medium',
794
+ 'upstream.thinkingHigh': 'High',
795
+ 'upstream.thinkingXhigh': 'Extra high',
796
  'upstream.promptOptimization': 'Prompt optimization',
797
+ 'upstream.forceWeb': 'Prefer a web account',
798
  'upstream.forceWebHint':
799
  'Only applies to upstream mixed groups and valid pixel ranges; it does not override user-provided APIs.',
800
  'generate.title': 'Write the idea',
 
805
  'edit.description': 'Modify an existing image with a text prompt.',
806
  'edit.submit': 'Edit image',
807
  'edit.loading': 'Editing...',
808
+ 'edit.sourceImages': 'Reference images (up to {count})',
809
  'edit.referenceTitle': 'Reference images',
810
  'edit.referenceHint': 'Up to {count} images. Add the image you want to keep working on first.',
811
  'edit.referenceCount': '{count} / {max} images',
812
  'edit.referenceAction': 'Add reference image',
813
+ 'edit.referenceEmpty': 'PNG, JPEG, and WebP are supported. Multiple images are allowed.',
814
  'edit.referenceAddMore': 'Add more images to the same reference set.',
815
  'edit.referenceReady': 'Reference image added. You can add more.',
816
  'edit.referencePreparing': 'Preparing reference preview.',
 
823
  'edit.mask': 'Mask',
824
  'edit.createMask': 'Create Mask',
825
  'edit.closeMaskEditor': 'Close Mask Editor',
826
+ 'edit.editSavedMask': 'Edit Current Mask',
827
  'edit.drawMaskHint':
828
  'Draw on the image below to mark areas for editing (drawn areas become transparent in the mask).',
829
  'edit.brushSize': 'Brush Size: {value}px',
830
  'edit.brushSizeLabel': 'Brush Size',
831
  'edit.uploadMask': 'Upload Mask',
832
  'edit.clearMask': 'Clear',
833
+ 'edit.saveMask': 'Save mask',
834
+ 'edit.maskPreview': 'Current Mask Preview:',
835
  'edit.maskPreviewLoading': 'Generating mask preview...',
836
+ 'edit.maskSaved': 'Mask saved for this edit.',
837
+ 'edit.maskAppliedStatus': 'Saved',
838
+ 'edit.maskApplied': 'Current mask: {name}',
839
  'edit.imagePreviewForMasking': 'Image preview for masking',
840
  'edit.generatedMaskPreviewAlt': 'Generated mask preview',
841
  'edit.sourcePreview': 'Source preview {index}',
 
853
  'output.keepalive': 'Connection kept alive...',
854
  'output.sendToEdit': 'Send to Edit',
855
  'output.continueEdit': 'Continue edit',
856
+ 'output.createVariant': 'Generate again with the same settings',
857
  'output.reusePrompt': 'Reuse prompt',
858
  'output.compare': 'Compare',
859
  'output.compareReference': 'Previous',
 
907
  'logs.status.idle': 'Disconnected',
908
  'logs.status.connected': 'Live connection active',
909
  'logs.status.error': 'Connection error',
910
+ 'logs.levelDebug': 'Debug',
911
+ 'logs.levelInfo': 'Info',
912
+ 'logs.levelWarn': 'Warning',
913
+ 'logs.levelError': 'Error',
914
  'share.pageTitle': 'Shared Image',
915
  'share.loading': 'Loading share details...',
916
  'share.loadFailed': 'Failed to load share.',
917
  'share.unlockFailed': 'Failed to open share.',
918
+ 'share.notFound': 'This share does not exist or has been removed.',
919
  'share.expired': 'This share has expired.',
920
+ 'share.accessCodeRequired': 'This share requires an access code.',
921
+ 'share.accessCodeInvalid': 'The access code is incorrect. Try again.',
922
+ 'share.accessCodeRateLimited': 'Too many access-code attempts. Try again later.',
923
  'share.unlock': 'Open',
924
  'share.accessCodePlaceholder': 'Enter access code',
925
  'share.accessCodeRequiredHint': 'Enter the access code to open the image.',
 
932
  'share.publicRiskHint':
933
  'Without an access code, anyone with the link can view the image. Keep an expiry enabled.',
934
  'share.expiry': 'Expiry',
935
+ 'share.expiryNone': 'No expiry set',
936
  'share.expiryOneHour': '1 hour',
937
  'share.expiryOneDay': '1 day',
938
  'share.expirySevenDays': '7 days',
 
942
  'share.create': 'Create Share',
943
  'share.createFailed': 'Failed to create share.',
944
  'history.title': 'Inspiration album',
945
+ 'history.inspirationAlbum': 'Inspiration',
946
  'history.recentGenerated': 'Recent',
947
+ 'history.inspirationEmpty': 'No saved prompts yet.',
948
+ 'history.applyInspiration': 'Apply prompt',
949
+ 'history.applyInspirationPrompt': 'Apply prompt: {title}',
950
  'history.applyHistory': 'Apply',
951
+ 'history.saveToInspiration': 'Save prompt',
952
  'history.reuseHistory': 'Reuse',
953
  'history.continueEdit': 'Edit',
954
  'history.saveHistoryPrompt': 'Save this history prompt as an inspiration',
 
964
  'history.resultFeedbackNoteEmpty': 'No reason recorded',
965
  'history.markResultUsable': 'Mark this result as usable',
966
  'history.markResultNeedsRevision': 'Mark this result as needing revision',
967
+ 'history.deleteInspiration': 'Delete prompt',
968
  'history.favorite': 'Favorite',
969
+ 'history.savedInspiration': 'Saved prompt',
970
  'history.template': 'Template',
971
  'history.newInspirationTemplate': 'New template',
972
+ 'history.applyFirstTemplate': 'Use first prompt',
973
+ 'history.inspirationCount': 'Saved prompts: {count}',
974
  'history.manage': 'Manage',
975
  'history.generationStatus': 'Generation status',
976
  'history.generationStatusHint': 'Requests, previews, saves, and failures update here quietly.',
977
  'history.statusDone': 'Generated',
978
  'history.statusCreateDone': 'New image saved',
979
  'history.statusEditDone': 'Edit saved',
980
+ 'history.statusBatchSummary': 'Images: {count}, {duration}',
981
+ 'history.statusFailedSummary': '{status}, {duration}',
982
  'history.activityToneProgress': 'Live',
983
  'history.activityToneSuccess': 'Done',
984
  'history.activityToneWarning': 'Note',
 
990
  'history.activityPreparingEdit': 'Preparing edit source',
991
  'history.activityPreparingEditDetail': 'Loading the current image before switching to the edit form.',
992
  'history.activityBatchProgress': 'Batch progress',
993
+ 'history.activityBatchProgressDetail': 'Completed {completed} of {total}.',
994
+ 'history.activityBatchProgressWithFailures': 'Completed {completed} of {total}; {failed} failed.',
995
  'history.activityStreaming': 'Streaming preview updated',
996
+ 'history.activityStreamingDetail': 'In-progress previews received: {count}.',
997
  'history.activitySaved': 'Images saved',
998
+ 'history.activitySavedDetail': 'Results ready to edit or download: {count}.',
999
  'history.activityFailed': 'Generation failed',
1000
  'history.activityFailedDetail': '{message} Recommendation: check API settings and retry, or switch channel.',
1001
  'history.empty': 'No generated images yet.',
 
1007
  'history.totalImages': 'Total Images Generated:',
1008
  'history.averageCost': 'Average Cost Per Image:',
1009
  'history.totalEstimatedCost': 'Total Estimated Cost:',
1010
+ 'history.actualCost': 'Matched upstream charge (USD equivalent):',
1011
+ 'history.actualCostShort': 'Matched charge',
1012
  'history.estimatedCostShort': 'Estimated',
1013
+ 'history.actualCostPending': 'Pending match',
1014
  'history.actualCostUnavailable': 'Not matched',
1015
  'history.costSource': 'Cost Source:',
1016
  'history.costConfidence': 'Match Confidence:',
1017
+ 'history.costSourceMatchedLog': 'Matched upstream charge log',
1018
+ 'history.costSourcePending': 'Waiting for an upstream charge record',
1019
+ 'history.costSourceUnavailable': 'No charge record matched',
1020
+ 'history.costSourceEstimate': 'Local estimate',
1021
+ 'history.costConfidenceExact': 'Exact',
1022
+ 'history.costConfidenceHigh': 'High',
1023
+ 'history.costConfidenceLow': 'Low',
1024
+ 'history.costConfidenceNone': 'None',
1025
+ 'history.costReasonPending': 'Waiting for an upstream charge record.',
1026
+ 'history.costReasonUnavailable': 'No usable upstream charge record was found.',
1027
+ 'history.costReasonLowConfidence': 'The upstream charge record could not be uniquely matched.',
1028
+ 'history.actualQuota': 'Upstream-reported quota:',
1029
  'history.matchedLogId': 'Log ID:',
1030
  'history.costBreakdown': 'Cost Breakdown',
1031
  'history.costBreakdownDescription': 'Estimated cost breakdown for this image generation.',
 
1038
  'history.imageInputTokens': 'Image Input Tokens:',
1039
  'history.imageOutputTokens': 'Image Output Tokens:',
1040
  'history.time': 'Time:',
1041
+ 'history.durationMilliseconds': '{value} ms',
1042
+ 'history.durationSeconds': '{value} s',
1043
+ 'history.durationMinutesSeconds': '{minutes}m {seconds}s',
1044
+ 'history.durationHoursMinutesSeconds': '{hours}h {minutes}m {seconds}s',
1045
  'history.generatedAt': 'Generated At:',
1046
  'history.mode': 'Mode:',
1047
  'history.model': 'Model:',
1048
  'history.resolution': 'Resolution:',
1049
  'history.quality': 'Quality:',
1050
+ 'history.bg': 'Background:',
1051
+ 'history.mod': 'Moderation:',
1052
  'history.imageCount': 'Image Count:',
1053
  'history.outputFormat': 'Output Format:',
1054
  'history.storage': 'Storage:',
 
1068
  'history.deleteItem': 'Delete history item',
1069
  'history.confirmDeletion': 'Confirm Deletion',
1070
  'history.confirmDeletionDescription':
1071
+ 'Are you sure you want to delete this history entry? Number of images to remove: {count}. This action cannot be undone.',
1072
  'history.dontAskAgain': "Don't ask me again",
1073
  'history.viewBatch': 'View image batch from {time}',
1074
  'history.previewBatch': 'Preview for batch generated at {time}',
 
1083
  'retention.selectHistory': 'Select recent images',
1084
  'retention.exitSelection': 'Done',
1085
  'retention.selectImage': 'Select image',
1086
+ 'retention.preserve': 'Protect from auto-cleanup',
1087
+ 'retention.release': 'Remove cleanup protection',
1088
+ 'retention.permanentlySaved': 'Protected from auto-cleanup',
1089
  'retention.selectedCount': '{count} selected',
1090
+ 'retention.hint':
1091
+ 'This only prevents server-side automatic cleanup. Manual deletion or clearing history still removes the image.',
1092
  'retention.updateFailed': 'Failed to update automatic-cleanup protection.',
1093
  'retention.partialUpdateFailed': 'Some image cleanup-protection settings were not updated.',
1094
  'retention.loadFailed': 'Failed to load automatic-cleanup protection.',
 
1096
  'history.showTotalCost': 'Show estimated cost summary',
1097
  'history.modeCreate': 'Create',
1098
  'history.modeEdit': 'Edit',
1099
+ 'history.storageFile': 'Server file storage',
1100
+ 'history.storageDb': 'Browser local database',
1101
  'history.generatedOn': 'Generated on: {time}',
1102
  'api.title': 'API Settings',
1103
  'api.description': 'Leave blank to use server environment variables. Saved only in this browser when filled.',
 
1126
  'error.authVerifyUnavailable': 'Could not refresh access. Check the network or try again later.',
1127
  'error.imageAccessRefreshFailed': 'Unable to refresh image access. Enter the access code again and retry.',
1128
  'error.unexpected': 'An unexpected error occurred.',
1129
+ 'error.responseBodyNull': 'The image service returned an empty response. Try again.',
1130
+ 'error.invalidRequest': 'The request is invalid. Check the input, images, and size, then try again.',
1131
  'error.streaming': 'Streaming error occurred',
1132
+ 'error.networkRequest': 'Could not reach the image service. Check the network and try again.',
1133
  'error.apiFailedWithAdvice': '{message}. Recommendation: {advice}',
1134
  'error.adviceAuth': 'Check the API Key, access code, or channel permissions.',
1135
  'error.adviceRateLimit': 'The request was rate limited. Retry later, or lower concurrency and image count.',
 
1137
  'The upstream service or API relay is failing. Retry later, or switch to another channel.',
1138
  'error.adviceCloudflare':
1139
  '4K or high-resolution generation may exceed the Cloudflare 100-second limit. Lower the resolution, or use an API relay that supports 4K streaming output: {url}',
1140
+ 'error.batchPartialFailure': 'Batch generation partially failed. Failed: {failed}/{total}.',
1141
  'error.batchPartialFailureDetailed':
1142
+ 'Batch generation partially failed. Failed: {failed}/{total}. Details: {reasons}',
1143
  'error.apiFailed': 'API request failed with status {status}',
1144
  'error.imageMissingBase64': 'Image {filename} missing base64 data in IndexedDB mode.',
1145
  'error.apiOmittedPaths': 'API response omitted one or more image paths.',
 
1152
  'Result feedback was saved locally, but server sync failed. It was queued and will retry on this page, then continue after refresh or reopen.',
1153
  'error.resultFeedbackDeleteSync':
1154
  'Local history was deleted, but server feedback cleanup failed. Cleanup was queued and will retry on this page, then continue after refresh or reopen.',
1155
+ 'error.clearHistory': 'Failed to clear history. Try again.',
1156
  'error.maxEditImages': 'Cannot add more than {count} images to the edit form.',
1157
  'error.imageNotFoundDb': 'Image {filename} not found in local database.',
1158
+ 'error.fetchImage': 'Failed to fetch image (HTTP {status}).',
1159
  'error.retrieveImage': 'Could not retrieve image data for {filename}.',
1160
+ 'error.downloadHistory': 'Could not create the history download. Try again.',
1161
  'error.sendToEdit': 'Failed to send image to edit form.',
1162
  'error.deleteUnexpected': 'An unexpected error occurred during deletion.',
1163
  'error.deletePartial':
1164
+ 'Some images could not be deleted, or their cleanup protection could not be fully cleared.',
1165
  'confirm.clearHistoryFs': 'Are you sure you want to clear the entire image history? This cannot be undone.',
1166
  'confirm.clearHistoryIndexedDb':
1167
  'Are you sure you want to clear the entire image history? In IndexedDB mode, this will also permanently delete all stored images. This cannot be undone.',
1168
  'workbench.creationSheet': 'Creation sheet',
1169
  'workbench.promptTitle': 'Write the idea',
1170
  'workbench.basicSettings': 'Basic settings',
1171
+ 'workbench.requestSummary': 'Images requested: {count}',
1172
+ 'workbench.saveInspiration': 'Save prompt',
1173
+ 'workbench.randomInspiration': 'Use a random saved prompt',
1174
  'workbench.album': 'Album',
1175
  'alert.editNoImage': 'Please select at least one image to edit.',
1176
  'alert.maxImages': 'You can only select up to {count} images.',
1177
+ 'alert.saveMaskBeforeSubmit': 'Save the drawn mask before submitting.',
1178
  'alert.maskInvalidType': 'Invalid file type. Please upload a PNG file for the mask.',
1179
  'alert.maskDimensionMismatch':
1180
  'Mask dimensions ({actual}) must match the source image dimensions ({expected}).',
 
1242
 
1243
  export function I18nProvider({ children }: { children: React.ReactNode }) {
1244
  const locale = React.useSyncExternalStore(subscribeToLocaleChanges, getLocaleSnapshot, () => defaultLocale);
1245
+ const t = React.useCallback(
1246
+ (key: string, values?: Record<string, string | number>) => {
1247
+ const template = messages[locale][key] ?? messages[defaultLocale][key] ?? key;
1248
+ return interpolate(template, values);
1249
+ },
1250
+ [locale]
1251
+ );
1252
 
1253
  React.useEffect(() => {
1254
+ const syncDocumentMetadata = () => {
1255
+ document.documentElement.lang = locale;
1256
+ const title = t('meta.title');
1257
+ if (document.title !== title) {
1258
+ document.title = title;
1259
+ }
1260
+ let description = document.querySelector<HTMLMetaElement>('meta[name="description"]');
1261
+ if (!description) {
1262
+ description = document.createElement('meta');
1263
+ description.name = 'description';
1264
+ document.head.appendChild(description);
1265
+ }
1266
+ const descriptionContent = t('meta.description');
1267
+ if (description.content !== descriptionContent) {
1268
+ description.content = descriptionContent;
1269
+ }
1270
+ };
1271
+
1272
+ const metadataMatchesLocale = () =>
1273
+ document.title === t('meta.title') &&
1274
+ document.querySelector<HTMLMetaElement>('meta[name="description"]')?.content === t('meta.description');
1275
+
1276
+ let animationFrameId: number | undefined;
1277
+ const scheduleMetadataSync = () => {
1278
+ if (animationFrameId !== undefined || metadataMatchesLocale()) return;
1279
+ animationFrameId = window.requestAnimationFrame(() => {
1280
+ animationFrameId = undefined;
1281
+ syncDocumentMetadata();
1282
+ });
1283
+ };
1284
+
1285
+ syncDocumentMetadata();
1286
+ scheduleMetadataSync();
1287
+
1288
+ const metadataObserver = new MutationObserver(scheduleMetadataSync);
1289
+ metadataObserver.observe(document.head, {
1290
+ attributes: true,
1291
+ attributeFilter: ['content'],
1292
+ childList: true,
1293
+ characterData: true,
1294
+ subtree: true
1295
+ });
1296
+
1297
+ return () => {
1298
+ metadataObserver.disconnect();
1299
+ if (animationFrameId !== undefined) {
1300
+ window.cancelAnimationFrame(animationFrameId);
1301
+ }
1302
+ };
1303
+ }, [locale, t]);
1304
 
1305
  React.useEffect(() => {
1306
  queueMicrotask(syncLocaleFromStorage);
 
1311
  emitLocale(nextLocale);
1312
  }, []);
1313
 
 
 
 
 
 
 
 
 
1314
  const value = React.useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
1315
 
1316
  return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
src/lib/image-display-labels.test.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ formatImageDurationLabel,
3
+ getActivityLogLevelLabel,
4
+ getImageBackgroundLabel,
5
+ getImageModerationLabel,
6
+ getImageOutputFormatLabel,
7
+ getImageQualityLabel
8
+ } from './image-display-labels';
9
+ import assert from 'node:assert/strict';
10
+ import { describe, it } from 'node:test';
11
+
12
+ const messages: Record<string, string> = {
13
+ 'common.auto': 'Automatic',
14
+ 'common.low': 'Low',
15
+ 'common.medium': 'Medium',
16
+ 'common.high': 'High',
17
+ 'common.png': 'PNG',
18
+ 'common.jpeg': 'JPEG',
19
+ 'common.webp': 'WebP',
20
+ 'form.backgroundOpaque': 'Opaque',
21
+ 'form.backgroundTransparent': 'Transparent',
22
+ 'history.durationMilliseconds': '{value} ms',
23
+ 'history.durationSeconds': '{value} s',
24
+ 'history.durationMinutesSeconds': '{minutes}m {seconds}s',
25
+ 'history.durationHoursMinutesSeconds': '{hours}h {minutes}m {seconds}s',
26
+ 'logs.levelDebug': 'Debug',
27
+ 'logs.levelInfo': 'Info',
28
+ 'logs.levelWarn': 'Warning',
29
+ 'logs.levelError': 'Error'
30
+ };
31
+
32
+ const translate = (key: string, values?: Record<string, string | number>): string =>
33
+ (messages[key] ?? key).replace(/\{(\w+)\}/g, (match, name) => String(values?.[name] ?? match));
34
+
35
+ describe('image display labels', () => {
36
+ it('localizes every supported image request enum', () => {
37
+ assert.equal(getImageQualityLabel('auto', translate), 'Automatic');
38
+ assert.equal(getImageQualityLabel('low', translate), 'Low');
39
+ assert.equal(getImageQualityLabel('medium', translate), 'Medium');
40
+ assert.equal(getImageQualityLabel('high', translate), 'High');
41
+ assert.equal(getImageBackgroundLabel('opaque', translate), 'Opaque');
42
+ assert.equal(getImageBackgroundLabel('transparent', translate), 'Transparent');
43
+ assert.equal(getImageModerationLabel('auto', translate), 'Automatic');
44
+ assert.equal(getImageModerationLabel('low', translate), 'Low');
45
+ assert.equal(getImageOutputFormatLabel('png', translate), 'PNG');
46
+ assert.equal(getImageOutputFormatLabel('jpeg', translate), 'JPEG');
47
+ assert.equal(getImageOutputFormatLabel('webp', translate), 'WebP');
48
+ });
49
+
50
+ it('keeps unexpected persisted values visible for diagnosis', () => {
51
+ assert.equal(getImageQualityLabel('experimental', translate), 'experimental');
52
+ assert.equal(getImageOutputFormatLabel('avif', translate), 'avif');
53
+ });
54
+
55
+ it('formats image durations with localized unit labels', () => {
56
+ assert.equal(formatImageDurationLabel(850, 'en-US', translate), '850 ms');
57
+ assert.equal(formatImageDurationLabel(1350, 'en-US', translate), '1.4 s');
58
+ assert.equal(formatImageDurationLabel(61_000, 'en-US', translate), '1m 01s');
59
+ assert.equal(formatImageDurationLabel(3_661_000, 'en-US', translate), '1h 01m 01s');
60
+ });
61
+
62
+ it('localizes known activity log levels and preserves unknown diagnostic values', () => {
63
+ assert.equal(getActivityLogLevelLabel('debug', translate), 'Debug');
64
+ assert.equal(getActivityLogLevelLabel('info', translate), 'Info');
65
+ assert.equal(getActivityLogLevelLabel('warn', translate), 'Warning');
66
+ assert.equal(getActivityLogLevelLabel('error', translate), 'Error');
67
+ assert.equal(getActivityLogLevelLabel('trace', translate), 'trace');
68
+ });
69
+ });
src/lib/image-display-labels.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ type Translate = (key: string, values?: Record<string, string | number>) => string;
2
+
3
+ export function getImageQualityLabel(value: string, t: Translate): string {
4
+ if (value === 'auto') return t('common.auto');
5
+ if (value === 'low') return t('common.low');
6
+ if (value === 'medium') return t('common.medium');
7
+ if (value === 'high') return t('common.high');
8
+ return value;
9
+ }
10
+
11
+ export function getImageBackgroundLabel(value: string, t: Translate): string {
12
+ if (value === 'auto') return t('common.auto');
13
+ if (value === 'opaque') return t('form.backgroundOpaque');
14
+ if (value === 'transparent') return t('form.backgroundTransparent');
15
+ return value;
16
+ }
17
+
18
+ export function getImageModerationLabel(value: string, t: Translate): string {
19
+ if (value === 'auto') return t('common.auto');
20
+ if (value === 'low') return t('common.low');
21
+ return value;
22
+ }
23
+
24
+ export function getImageOutputFormatLabel(value: string, t: Translate): string {
25
+ if (value === 'png') return t('common.png');
26
+ if (value === 'jpeg') return t('common.jpeg');
27
+ if (value === 'webp') return t('common.webp');
28
+ return value;
29
+ }
30
+
31
+ export function formatImageDurationLabel(value: number, locale: string, t: Translate): string {
32
+ const integerFormatter = new Intl.NumberFormat(locale, { useGrouping: false });
33
+ const paddedIntegerFormatter = new Intl.NumberFormat(locale, {
34
+ minimumIntegerDigits: 2,
35
+ useGrouping: false
36
+ });
37
+
38
+ if (value < 1000) {
39
+ return t('history.durationMilliseconds', { value: integerFormatter.format(value) });
40
+ }
41
+
42
+ const totalSeconds = Math.round(value / 1000);
43
+ if (totalSeconds < 60) {
44
+ const seconds = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value / 1000);
45
+ return t('history.durationSeconds', { value: seconds });
46
+ }
47
+
48
+ const hours = Math.floor(totalSeconds / 3600);
49
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
50
+ const seconds = totalSeconds % 60;
51
+ const formattedMinutes = paddedIntegerFormatter.format(minutes);
52
+ const formattedSeconds = paddedIntegerFormatter.format(seconds);
53
+
54
+ if (hours > 0) {
55
+ return t('history.durationHoursMinutesSeconds', {
56
+ hours: integerFormatter.format(hours),
57
+ minutes: formattedMinutes,
58
+ seconds: formattedSeconds
59
+ });
60
+ }
61
+
62
+ return t('history.durationMinutesSeconds', {
63
+ minutes: integerFormatter.format(minutes),
64
+ seconds: formattedSeconds
65
+ });
66
+ }
67
+
68
+ export function getActivityLogLevelLabel(value: string, t: Translate): string {
69
+ if (value === 'debug') return t('logs.levelDebug');
70
+ if (value === 'info') return t('logs.levelInfo');
71
+ if (value === 'warn') return t('logs.levelWarn');
72
+ if (value === 'error') return t('logs.levelError');
73
+ return value;
74
+ }
src/lib/share-api-error.test.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { resolveShareApiErrorMessage } from './share-api-error';
2
+ import assert from 'node:assert/strict';
3
+ import { describe, it } from 'node:test';
4
+
5
+ const t = (key: string) => `translated:${key}`;
6
+
7
+ describe('resolveShareApiErrorMessage', () => {
8
+ it('maps known share error codes to locale keys without rendering API error text', () => {
9
+ assert.equal(
10
+ resolveShareApiErrorMessage({ code: 'share_not_found', error: '分享不存在。' }, t, 'share.loadFailed'),
11
+ 'translated:share.notFound'
12
+ );
13
+ assert.equal(
14
+ resolveShareApiErrorMessage(
15
+ { code: 'share_access_denied', error: '访问码无效。' },
16
+ t,
17
+ 'share.unlockFailed'
18
+ ),
19
+ 'translated:share.accessCodeInvalid'
20
+ );
21
+ assert.equal(
22
+ resolveShareApiErrorMessage(
23
+ { code: 'share_rate_limited', error: '访问码尝试次数过多。' },
24
+ t,
25
+ 'share.unlockFailed'
26
+ ),
27
+ 'translated:share.accessCodeRateLimited'
28
+ );
29
+ });
30
+
31
+ it('uses the localized fallback for malformed and unknown API errors', () => {
32
+ assert.equal(
33
+ resolveShareApiErrorMessage({ error: 'raw response' }, t, 'share.loadFailed'),
34
+ 'translated:share.loadFailed'
35
+ );
36
+ assert.equal(
37
+ resolveShareApiErrorMessage({ code: 'unknown_error', error: 'raw response' }, t, 'share.unlockFailed'),
38
+ 'translated:share.unlockFailed'
39
+ );
40
+ });
41
+ });
src/lib/share-api-error.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ type Translate = (key: string) => string;
2
+
3
+ const translationKeyByErrorCode: Record<string, string> = {
4
+ share_not_found: 'share.notFound',
5
+ share_expired: 'share.expired',
6
+ share_access_code_required: 'share.accessCodeRequired',
7
+ share_access_denied: 'share.accessCodeInvalid',
8
+ share_rate_limited: 'share.accessCodeRateLimited'
9
+ };
10
+
11
+ export function resolveShareApiErrorMessage(value: unknown, t: Translate, fallbackKey: string): string {
12
+ if (!isRecord(value) || typeof value.code !== 'string') return t(fallbackKey);
13
+ return t(translationKeyByErrorCode[value.code] ?? fallbackKey);
14
+ }
15
+
16
+ function isRecord(value: unknown): value is Record<string, unknown> {
17
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
18
+ }
src/lib/share-client.test.ts CHANGED
@@ -83,4 +83,20 @@ describe('createImageShareFromBlob', () => {
83
  /refresh failed/
84
  );
85
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  });
 
83
  /refresh failed/
84
  );
85
  });
86
+
87
+ it('uses the localized failure message instead of exposing an API error response', async () => {
88
+ await assert.rejects(
89
+ () =>
90
+ createImageShareFromBlob({
91
+ filename: 'result.png',
92
+ blob: new Blob([Buffer.from('image-bytes')], { type: 'image/png' }),
93
+ values: { accessCode: '', expiresInMinutes: null },
94
+ accessRefreshErrorMessage: 'refresh failed',
95
+ createFailedMessage: 'create failed',
96
+ refreshImageAccessCookie: async () => true,
97
+ fetchImpl: async () => Response.json({ error: 'raw API error' }, { status: 400 })
98
+ }),
99
+ /create failed/
100
+ );
101
+ });
102
  });
src/lib/share-client.ts CHANGED
@@ -43,10 +43,15 @@ export async function createImageShareFromBlob(options: CreateImageShareFromBlob
43
  form.set('expiresInMinutes', String(options.values.expiresInMinutes));
44
  }
45
 
46
- const response = await (options.fetchImpl ?? fetch)('/api/shares', { method: 'POST', body: form });
47
- const body = (await response.json().catch(() => ({}))) as { error?: unknown; url?: unknown };
 
 
 
 
 
48
  if (!response.ok) {
49
- throw new Error(typeof body.error === 'string' ? body.error : options.createFailedMessage);
50
  }
51
  if (typeof body.url !== 'string' || !body.url) {
52
  throw new Error(options.createFailedMessage);
 
43
  form.set('expiresInMinutes', String(options.values.expiresInMinutes));
44
  }
45
 
46
+ let response: Response;
47
+ try {
48
+ response = await (options.fetchImpl ?? fetch)('/api/shares', { method: 'POST', body: form });
49
+ } catch {
50
+ throw new Error(options.createFailedMessage);
51
+ }
52
+ const body = (await response.json().catch(() => ({}))) as { url?: unknown };
53
  if (!response.ok) {
54
+ throw new Error(options.createFailedMessage);
55
  }
56
  if (typeof body.url !== 'string' || !body.url) {
57
  throw new Error(options.createFailedMessage);
src/lib/streaming-batch.ts CHANGED
@@ -80,6 +80,7 @@ export type ApiImageResponseItem = {
80
 
81
  export type StreamingClientEvent = {
82
  type?: string;
 
83
  index?: number;
84
  b64_json?: string;
85
  filename?: string;
 
80
 
81
  export type StreamingClientEvent = {
82
  type?: string;
83
+ status?: number;
84
  index?: number;
85
  b64_json?: string;
86
  filename?: string;
src/lib/streaming-status-label.test.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getStreamingStatusLabel } from './streaming-status-label';
2
+ import assert from 'node:assert/strict';
3
+ import { describe, it } from 'node:test';
4
+
5
+ const labels: Record<string, string> = {
6
+ 'streaming.modeAuto': '自动',
7
+ 'streaming.modeStream': '强制流式',
8
+ 'streaming.modeNonStream': '非流式'
9
+ };
10
+
11
+ const t = (key: string): string => labels[key] ?? key;
12
+
13
+ describe('getStreamingStatusLabel', () => {
14
+ it('describes the selected automatic mode without claiming streaming availability', () => {
15
+ assert.equal(getStreamingStatusLabel('auto', t), '自动');
16
+ });
17
+
18
+ it('distinguishes forced streaming from non-streaming mode', () => {
19
+ assert.equal(getStreamingStatusLabel('stream', t), '强制流式');
20
+ assert.equal(getStreamingStatusLabel('non_stream', t), '非流式');
21
+ });
22
+ });
src/lib/streaming-status-label.ts CHANGED
@@ -3,6 +3,7 @@ import type { ImageStreamMode } from '@/lib/image-upstream-strategy';
3
  type Translate = (key: string) => string;
4
 
5
  export function getStreamingStatusLabel(streamMode: ImageStreamMode, t: Translate): string {
6
- if (streamMode === 'non_stream') return t('streaming.statusStandard');
7
- return t('streaming.statusAvailable');
 
8
  }
 
3
  type Translate = (key: string) => string;
4
 
5
  export function getStreamingStatusLabel(streamMode: ImageStreamMode, t: Translate): string {
6
+ if (streamMode === 'stream') return t('streaming.modeStream');
7
+ if (streamMode === 'non_stream') return t('streaming.modeNonStream');
8
+ return t('streaming.modeAuto');
9
  }
src/lib/webui-image-retention-client.test.ts CHANGED
@@ -1,6 +1,5 @@
1
  import {
2
  mergeWebuiImageRetentionResults,
3
- readApiErrorMessage,
4
  readWebuiImageFileOperationResults,
5
  readWebuiImageRetentionFilenames
6
  } from './webui-image-retention-client';
@@ -8,7 +7,7 @@ import assert from 'node:assert/strict';
8
  import { describe, it } from 'node:test';
9
 
10
  describe('WebUI image retention client contract', () => {
11
- it('parses permanent filenames and file operation results from valid API responses', () => {
12
  assert.deepEqual(readWebuiImageRetentionFilenames({ filenames: ['one.png', 'two.png', 'one.png'] }), [
13
  'one.png',
14
  'two.png'
@@ -37,16 +36,14 @@ describe('WebUI image retention client contract', () => {
37
  }
38
  ]
39
  );
40
- assert.equal(readApiErrorMessage({ error: '未授权。' }), '未授权。');
41
  });
42
 
43
  it('rejects malformed API payloads instead of silently accepting them', () => {
44
  assert.throws(() => readWebuiImageRetentionFilenames({ filenames: ['one.png', 2] }), /格式无效/);
45
  assert.throws(() => readWebuiImageFileOperationResults({ results: [{ filename: 'one.png' }] }), /格式无效/);
46
- assert.equal(readApiErrorMessage({ message: 'not an error field' }), undefined);
47
  });
48
 
49
- it('merges only confirmed permanent-state changes into the filename set', () => {
50
  const results = [
51
  { filename: 'kept.png', success: true },
52
  {
@@ -61,7 +58,7 @@ describe('WebUI image retention client contract', () => {
61
  success: false,
62
  fileDeleted: true,
63
  markerRemoved: false,
64
- error: '图片已删除,但永久存状态清理失败。'
65
  }
66
  ];
67
 
 
1
  import {
2
  mergeWebuiImageRetentionResults,
 
3
  readWebuiImageFileOperationResults,
4
  readWebuiImageRetentionFilenames
5
  } from './webui-image-retention-client';
 
7
  import { describe, it } from 'node:test';
8
 
9
  describe('WebUI image retention client contract', () => {
10
+ it('parses cleanup-protected filenames and file operation results from valid API responses', () => {
11
  assert.deepEqual(readWebuiImageRetentionFilenames({ filenames: ['one.png', 'two.png', 'one.png'] }), [
12
  'one.png',
13
  'two.png'
 
36
  }
37
  ]
38
  );
 
39
  });
40
 
41
  it('rejects malformed API payloads instead of silently accepting them', () => {
42
  assert.throws(() => readWebuiImageRetentionFilenames({ filenames: ['one.png', 2] }), /格式无效/);
43
  assert.throws(() => readWebuiImageFileOperationResults({ results: [{ filename: 'one.png' }] }), /格式无效/);
 
44
  });
45
 
46
+ it('merges only confirmed cleanup-protection changes into the filename set', () => {
47
  const results = [
48
  { filename: 'kept.png', success: true },
49
  {
 
58
  success: false,
59
  fileDeleted: true,
60
  markerRemoved: false,
61
+ error: '图片已删除,但自动清理护未能清理。'
62
  }
63
  ];
64
 
src/lib/webui-image-retention-client.ts CHANGED
@@ -11,10 +11,10 @@ export type WebuiImageFileOperationResult = {
11
 
12
  export function readWebuiImageRetentionFilenames(value: unknown): string[] {
13
  if (!isRecord(value) || !Array.isArray(value.filenames)) {
14
- throw new Error('永久状态响应格式无效。');
15
  }
16
  if (!value.filenames.every((filename): filename is string => typeof filename === 'string')) {
17
- throw new Error('永久状态响应格式无效。');
18
  }
19
  return [...new Set(value.filenames)];
20
  }
@@ -38,10 +38,6 @@ export function readWebuiImageFileOperationResults(value: unknown): WebuiImageFi
38
  });
39
  }
40
 
41
- export function readApiErrorMessage(value: unknown): string | undefined {
42
- return isRecord(value) && typeof value.error === 'string' ? value.error : undefined;
43
- }
44
-
45
  export function mergeWebuiImageRetentionResults(
46
  current: ReadonlySet<string>,
47
  action: WebuiImageRetentionAction,
 
11
 
12
  export function readWebuiImageRetentionFilenames(value: unknown): string[] {
13
  if (!isRecord(value) || !Array.isArray(value.filenames)) {
14
+ throw new Error('自动清理状态响应格式无效。');
15
  }
16
  if (!value.filenames.every((filename): filename is string => typeof filename === 'string')) {
17
+ throw new Error('自动清理状态响应格式无效。');
18
  }
19
  return [...new Set(value.filenames)];
20
  }
 
38
  });
39
  }
40
 
 
 
 
 
41
  export function mergeWebuiImageRetentionResults(
42
  current: ReadonlySet<string>,
43
  action: WebuiImageRetentionAction,
src/lib/workbench-cost-label.test.ts DELETED
@@ -1,16 +0,0 @@
1
- import { formatEstimatedCredits } from './workbench-cost-label';
2
- import assert from 'node:assert/strict';
3
- import { describe, it } from 'node:test';
4
-
5
- describe('formatEstimatedCredits', () => {
6
- it('scales the visible estimate by the current task count', () => {
7
- assert.equal(formatEstimatedCredits(1), '0.12');
8
- assert.equal(formatEstimatedCredits(2), '0.24');
9
- assert.equal(formatEstimatedCredits(4), '0.48');
10
- });
11
-
12
- it('shows zero credits for empty task counts and one task for invalid counts', () => {
13
- assert.equal(formatEstimatedCredits(0), '0.00');
14
- assert.equal(formatEstimatedCredits(Number.NaN), '0.12');
15
- });
16
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/workbench-cost-label.ts DELETED
@@ -1,7 +0,0 @@
1
- const ESTIMATED_CREDITS_PER_IMAGE = 0.12;
2
-
3
- export function formatEstimatedCredits(taskCount: number): string {
4
- const safeTaskCount = Number.isFinite(taskCount) ? Math.max(0, Math.floor(taskCount)) : 1;
5
- const credits = ESTIMATED_CREDITS_PER_IMAGE * safeTaskCount;
6
- return credits.toFixed(2);
7
- }