Spaces:
Running
Running
Visual Journal deploy commited on
Commit ·
e4e0afe
1
Parent(s): 84810e9
Deploy 5554830 to Docker Space
Browse files- README.md +3 -3
- docs/ui/literary-young-women-workbench-design.md +11 -11
- public/hf-space-deploy-marker.json +3 -3
- src/app/api/deploy-marker/route.ts +1 -1
- src/app/api/image-delete/route.test.ts +4 -4
- src/app/api/image-delete/route.ts +3 -3
- src/app/api/image-retention/route.ts +5 -5
- src/app/layout.tsx +2 -2
- src/app/page-regressions.test.ts +44 -0
- src/app/page.tsx +164 -79
- src/app/share/[token]/page-regressions.test.ts +10 -0
- src/app/share/[token]/page.tsx +19 -10
- src/components/editing-form.test.tsx +8 -8
- src/components/editing-form.tsx +29 -29
- src/components/generation-form.test.tsx +8 -8
- src/components/generation-form.tsx +31 -31
- src/components/history-panel.test.tsx +42 -13
- src/components/history-panel.tsx +77 -43
- src/components/image-output.test.tsx +17 -2
- src/components/image-output.tsx +9 -7
- src/components/language-selector.test.tsx +83 -0
- src/components/language-selector.tsx +39 -0
- src/components/mode-toggle.test.tsx +1 -1
- src/components/password-dialog.tsx +2 -8
- src/components/share-dialog.test.tsx +1 -1
- src/components/workbench-pro-dock-panels.tsx +9 -14
- src/components/workbench-pro-dock.test.tsx +33 -0
- src/components/workbench-status-strip.test.tsx +27 -26
- src/components/workbench-status-strip.tsx +3 -3
- src/lib/i18n-contract.test.ts +250 -0
- src/lib/i18n.tsx +262 -136
- src/lib/image-display-labels.test.ts +69 -0
- src/lib/image-display-labels.ts +74 -0
- src/lib/share-api-error.test.ts +41 -0
- src/lib/share-api-error.ts +18 -0
- src/lib/share-client.test.ts +16 -0
- src/lib/share-client.ts +8 -3
- src/lib/streaming-batch.ts +1 -0
- src/lib/streaming-status-label.test.ts +22 -0
- src/lib/streaming-status-label.ts +3 -2
- src/lib/webui-image-retention-client.test.ts +3 -6
- src/lib/webui-image-retention-client.ts +2 -6
- src/lib/workbench-cost-label.test.ts +0 -16
- 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/
|
| 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/
|
| 75 |
-
<img src="https://raw.githubusercontent.com/MisonL/visual-journal/
|
| 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 |
-
-
|
| 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 |
-
-
|
| 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、流式
|
| 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": "
|
| 4 |
-
"created_at": "2026-07-
|
| 5 |
-
"deploy_id": "
|
| 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":"
|
| 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
|
| 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
|
| 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('删除图片后清理
|
| 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('读取
|
| 34 |
-
return NextResponse.json({ error: '读取
|
| 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('更新
|
| 55 |
-
return NextResponse.json({ error: '更新
|
| 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('校验
|
| 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: '图像手记 |
|
| 8 |
-
description: '图像手记
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
if (error instanceof ApiRequestError) {
|
| 440 |
return { message: error.message, status: error.status };
|
| 441 |
}
|
| 442 |
-
if (error instanceof
|
| 443 |
-
return { message:
|
| 444 |
}
|
| 445 |
return { message: fallbackMessage };
|
| 446 |
}
|
|
@@ -759,19 +768,19 @@ export default function HomePage() {
|
|
| 759 |
streamingStrategy: activeEffectiveStreamingStrategy,
|
| 760 |
streamMode
|
| 761 |
});
|
| 762 |
-
const
|
| 763 |
mode === 'generate' && workbenchMode === 'batch'
|
| 764 |
? readBatchPromptLines(genBatchPromptText).length
|
| 765 |
: mode === 'generate'
|
| 766 |
? genN[0]
|
| 767 |
: editN[0];
|
| 768 |
-
const
|
| 769 |
-
|
| 770 |
});
|
| 771 |
const activeParallelBatchVisible = resolveStreamingBatchToggleState({
|
| 772 |
allowStreamingBatch: streamingBatchEnabled,
|
| 773 |
userEnabled: enableParallelBatch,
|
| 774 |
-
targetCount:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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('读取
|
| 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 |
-
|
| 1419 |
-
|
| 1420 |
-
|
| 1421 |
-
|
| 1422 |
-
|
| 1423 |
-
|
| 1424 |
-
|
| 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(
|
| 1435 |
}
|
| 1436 |
|
| 1437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 |
-
|
| 1791 |
-
|
| 1792 |
-
|
| 1793 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1794 |
|
| 1795 |
const contentType = response.headers.get('content-type');
|
| 1796 |
if (contentType?.includes('text/event-stream')) {
|
| 1797 |
if (!response.body) {
|
| 1798 |
-
throw new
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 1826 |
} else if (event.type === 'completed' || event.type === 'done') {
|
| 1827 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1828 |
}
|
| 1829 |
};
|
| 1830 |
|
| 1831 |
while (true) {
|
| 1832 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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
|
| 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
|
| 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
|
| 3152 |
}
|
| 3153 |
return record.blob;
|
| 3154 |
}
|
| 3155 |
|
| 3156 |
if (!(await refreshImageAccessCookie())) {
|
| 3157 |
-
throw new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3158 |
}
|
| 3159 |
-
const response = await fetch(`/api/image/${filename}`);
|
| 3160 |
if (!response.ok) {
|
| 3161 |
-
throw new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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
|
| 3303 |
}
|
| 3304 |
} else {
|
| 3305 |
if (!(await refreshImageAccessCookie())) {
|
| 3306 |
return false;
|
| 3307 |
}
|
| 3308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3325 |
}
|
| 3326 |
-
blob = await response.blob();
|
| 3327 |
mimeType = response.headers.get('Content-Type') || mimeType;
|
| 3328 |
}
|
| 3329 |
|
| 3330 |
if (!blob) {
|
| 3331 |
-
throw new
|
| 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
|
| 3349 |
setError(createErrorNotice(errorMessage));
|
| 3350 |
return false;
|
| 3351 |
} finally {
|
|
@@ -3449,19 +3515,34 @@ export default function HomePage() {
|
|
| 3449 |
apiPayload.passwordHash = clientPasswordHash;
|
| 3450 |
}
|
| 3451 |
|
| 3452 |
-
|
| 3453 |
-
|
| 3454 |
-
|
| 3455 |
-
|
| 3456 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3457 |
|
| 3458 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3459 |
if (!response.ok) {
|
| 3460 |
-
throw new
|
| 3461 |
-
|
| 3462 |
);
|
| 3463 |
}
|
| 3464 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 3844 |
/>
|
| 3845 |
</div>
|
| 3846 |
<div
|
|
@@ -3935,7 +4020,7 @@ export default function HomePage() {
|
|
| 3935 |
setEditPromptOptimization={setEditPromptOptimization}
|
| 3936 |
editForceWeb={editForceWeb}
|
| 3937 |
setEditForceWeb={setEditForceWeb}
|
| 3938 |
-
|
| 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
|
| 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
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
if (!imageResponse.headers.get('content-type')?.startsWith('image/')) {
|
| 62 |
-
throw new
|
| 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
|
| 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
|
| 105 |
}
|
| 106 |
if (!response.headers.get('content-type')?.startsWith('image/')) {
|
| 107 |
-
throw new
|
| 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
|
| 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 |
-
|
| 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, /
|
| 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 |
-
|
| 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 |
-
|
| 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')}: ${
|
| 463 |
-
`${t('form.outputFormat')}: ${
|
| 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('
|
| 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'>
|
| 1503 |
-
|
| 1504 |
-
<
|
| 1505 |
-
<SelectItem value='
|
| 1506 |
-
|
| 1507 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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='
|
| 1768 |
Icon={FileImage}
|
| 1769 |
disabled={isLoading}
|
| 1770 |
/>
|
| 1771 |
<RadioItemWithIcon
|
| 1772 |
value='jpeg'
|
| 1773 |
id='edit-format-jpeg'
|
| 1774 |
-
label='
|
| 1775 |
Icon={FileImage}
|
| 1776 |
disabled={isLoading}
|
| 1777 |
/>
|
| 1778 |
<RadioItemWithIcon
|
| 1779 |
value='webp'
|
| 1780 |
id='edit-format-webp'
|
| 1781 |
-
label='
|
| 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 |
-
{
|
| 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 |
-
|
| 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, /
|
| 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]*随
|
| 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 |
-
|
| 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 |
-
|
| 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')}: ${
|
| 475 |
-
`${t('form.outputFormat')}: ${
|
| 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='
|
| 892 |
Icon={FileImage}
|
| 893 |
disabled={isLoading}
|
| 894 |
/>
|
| 895 |
<RadioItemWithIcon
|
| 896 |
value='png'
|
| 897 |
id='format-png-quick'
|
| 898 |
-
label='
|
| 899 |
Icon={FileImage}
|
| 900 |
disabled={isLoading}
|
| 901 |
/>
|
| 902 |
<RadioItemWithIcon
|
| 903 |
value='webp'
|
| 904 |
id='format-webp-quick'
|
| 905 |
-
label='
|
| 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'>
|
| 1138 |
-
|
| 1139 |
-
<
|
| 1140 |
-
<SelectItem value='
|
| 1141 |
-
|
| 1142 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1143 |
</SelectContent>
|
| 1144 |
</Select>
|
| 1145 |
</div>
|
|
@@ -1437,21 +1437,21 @@ export function GenerationForm({
|
|
| 1437 |
<RadioItemWithIcon
|
| 1438 |
value='png'
|
| 1439 |
id='format-png'
|
| 1440 |
-
label='
|
| 1441 |
Icon={FileImage}
|
| 1442 |
disabled={isLoading}
|
| 1443 |
/>
|
| 1444 |
<RadioItemWithIcon
|
| 1445 |
value='jpeg'
|
| 1446 |
id='format-jpeg'
|
| 1447 |
-
label='
|
| 1448 |
Icon={FileImage}
|
| 1449 |
disabled={isLoading}
|
| 1450 |
/>
|
| 1451 |
<RadioItemWithIcon
|
| 1452 |
value='webp'
|
| 1453 |
id='format-webp'
|
| 1454 |
-
label='
|
| 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 |
-
{
|
| 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
|
| 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
|
| 136 |
};
|
| 137 |
|
| 138 |
const formatMoney = (value: number): string => value.toFixed(4);
|
| 139 |
-
const formatEstimatedTokenCost = (value: number, rate: number): string =>
|
|
|
|
|
|
|
|
|
|
| 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('更新图片
|
| 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>{
|
| 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 |
-
{
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 921 |
</span>
|
| 922 |
</div>
|
| 923 |
{typeof item.actualCostDetails
|
|
@@ -947,9 +977,9 @@ function HistoryPanelImpl({
|
|
| 947 |
</span>
|
| 948 |
</div>
|
| 949 |
)}
|
| 950 |
-
{
|
| 951 |
<p className='text-xs'>
|
| 952 |
-
{
|
| 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 |
-
?
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 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
|
| 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
|
| 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={
|
| 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('
|
| 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,
|
| 53 |
const resolution = getPresetDimensions(size, model);
|
| 54 |
if (resolution) return resolution;
|
| 55 |
-
if (size === 'custom') return
|
| 56 |
return '1024 px';
|
| 57 |
}
|
| 58 |
|
|
@@ -78,8 +73,8 @@ export function WorkbenchEasySummary({
|
|
| 78 |
streamingStrategy,
|
| 79 |
defaultStreamingStrategy
|
| 80 |
}: ProPanelProps) {
|
| 81 |
-
const {
|
| 82 |
-
const resolution = formatResolution(size, model,
|
| 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')}>{
|
| 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 |
-
{
|
| 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 {
|
| 301 |
-
const resolution = formatResolution(props.size, props.model,
|
| 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
|
| 9 |
const html = renderToStaticMarkup(
|
| 10 |
<I18nProvider>
|
| 11 |
<WorkbenchStatusStrip
|
| 12 |
model='gpt-image-2'
|
| 13 |
routeLabel='images-sse'
|
| 14 |
-
streamStatus='
|
| 15 |
-
|
| 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, /
|
| 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 |
-
|
| 36 |
/>
|
| 37 |
</I18nProvider>
|
| 38 |
);
|
|
@@ -46,8 +46,8 @@ describe('WorkbenchStatusStrip', () => {
|
|
| 46 |
<WorkbenchStatusStrip
|
| 47 |
model='gpt-image-2'
|
| 48 |
routeLabel='默认线路'
|
| 49 |
-
streamStatus='
|
| 50 |
-
|
| 51 |
/>
|
| 52 |
</I18nProvider>
|
| 53 |
);
|
|
@@ -64,8 +64,8 @@ describe('WorkbenchStatusStrip', () => {
|
|
| 64 |
<WorkbenchStatusStrip
|
| 65 |
model='gpt-image-2'
|
| 66 |
routeLabel='服务器默认'
|
| 67 |
-
streamStatus='
|
| 68 |
-
|
| 69 |
runtimeHealthStatus='checking'
|
| 70 |
/>
|
| 71 |
</I18nProvider>
|
|
@@ -75,8 +75,8 @@ describe('WorkbenchStatusStrip', () => {
|
|
| 75 |
<WorkbenchStatusStrip
|
| 76 |
model='gpt-image-2'
|
| 77 |
routeLabel='服务器默认'
|
| 78 |
-
streamStatus='
|
| 79 |
-
|
| 80 |
runtimeHealthStatus='disconnected'
|
| 81 |
/>
|
| 82 |
</I18nProvider>
|
|
@@ -86,8 +86,8 @@ describe('WorkbenchStatusStrip', () => {
|
|
| 86 |
<WorkbenchStatusStrip
|
| 87 |
model='gpt-image-2'
|
| 88 |
routeLabel='服务器默认'
|
| 89 |
-
streamStatus='
|
| 90 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
{
|
| 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} 像素
|
|
|
|
| 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': '
|
| 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
|
| 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': '仅在上游
|
| 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': '
|
| 213 |
'edit.referenceTitle': '参考图',
|
| 214 |
'edit.referenceHint': '最多 {count} 张,先放入想继续修改的图片。',
|
| 215 |
'edit.referenceCount': '{count} / {max} 张',
|
| 216 |
'edit.referenceAction': '放入参考图',
|
| 217 |
-
'edit.referenceEmpty': '支持 PNG、
|
| 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.
|
|
|
|
| 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': '套用
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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': '清空历史失败
|
| 517 |
'error.maxEditImages': '编辑表单最多只能添加 {count} 张图片。',
|
| 518 |
'error.imageNotFoundDb': '本地数据库中找不到图片 {filename}。',
|
| 519 |
-
'error.fetchImage': '获取图片失败
|
| 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.
|
| 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': '
|
| 555 |
-
'app.apiRuntimeReady': '
|
| 556 |
-
'app.apiRouteLimited': '
|
| 557 |
-
'app.apiDisconnected': 'Runtime
|
| 558 |
'app.apiCustomOverride': 'Custom upstream',
|
| 559 |
'app.requestModeHealth': 'Request modes',
|
| 560 |
-
'app.requestModeVerified': '
|
| 561 |
'app.requestModeNotConfigured': 'Not configured',
|
| 562 |
'app.requestModeCoolingOrProbe': 'Cooling or pending probe',
|
| 563 |
'app.recentChannelFailure': 'Recent channel failure',
|
| 564 |
-
'app.suggestedRequestModes': 'Suggested
|
| 565 |
'mode.generate': 'Text to image',
|
| 566 |
-
'mode.edit': 'Image
|
| 567 |
'mode.batch': 'Batch',
|
| 568 |
-
'mode.reuse': 'Reuse',
|
| 569 |
'mode.generateDescription': 'Start with an idea',
|
| 570 |
-
'mode.editDescription': '
|
| 571 |
'mode.batchDescription': 'Multiple outputs',
|
| 572 |
-
'mode.reuseDescription': 'Reuse
|
| 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
|
| 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': '
|
| 656 |
-
'form.editPromptPlaceholder': '
|
| 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
|
|
|
|
| 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':
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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': '
|
| 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,
|
| 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
|
| 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
|
| 784 |
-
'edit.maskPreview': '
|
| 785 |
'edit.maskPreviewLoading': 'Generating mask preview...',
|
| 786 |
-
'edit.maskSaved': 'Mask saved
|
| 787 |
-
'edit.
|
|
|
|
| 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': '
|
| 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': '
|
| 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': '
|
| 887 |
'history.recentGenerated': 'Recent',
|
| 888 |
-
'history.inspirationEmpty': 'No saved
|
| 889 |
-
'history.applyInspiration': 'Apply',
|
| 890 |
-
'history.applyInspirationPrompt': 'Apply
|
| 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
|
| 909 |
'history.favorite': 'Favorite',
|
| 910 |
-
'history.savedInspiration': 'Saved
|
| 911 |
'history.template': 'Template',
|
| 912 |
'history.newInspirationTemplate': 'New template',
|
| 913 |
-
'history.applyFirstTemplate': '
|
| 914 |
-
'history.inspirationCount': '{count}
|
| 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}
|
|
|
|
| 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}
|
| 934 |
-
'history.activityBatchProgressWithFailures': 'Completed {completed}
|
| 935 |
'history.activityStreaming': 'Streaming preview updated',
|
| 936 |
-
'history.activityStreamingDetail': '
|
| 937 |
'history.activitySaved': 'Images saved',
|
| 938 |
-
'history.activitySavedDetail': '
|
| 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': '
|
| 951 |
-
'history.actualCostShort': '
|
| 952 |
'history.estimatedCostShort': 'Estimated',
|
| 953 |
-
'history.actualCostPending': '
|
| 954 |
'history.actualCostUnavailable': 'Not matched',
|
| 955 |
'history.costSource': 'Cost Source:',
|
| 956 |
'history.costConfidence': 'Match Confidence:',
|
| 957 |
-
'history.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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': '
|
| 976 |
-
'history.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?
|
| 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': '
|
| 1012 |
-
'retention.release': 'Remove
|
| 1013 |
-
'retention.permanentlySaved': '
|
| 1014 |
'retention.selectedCount': '{count} selected',
|
| 1015 |
-
'retention.hint':
|
|
|
|
| 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': '
|
| 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': '
|
|
|
|
| 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}
|
| 1063 |
'error.batchPartialFailureDetailed':
|
| 1064 |
-
'Batch generation partially failed: {failed}/{total}
|
| 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
|
| 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
|
| 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
|
| 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.
|
| 1093 |
-
'workbench.saveInspiration': 'Save
|
| 1094 |
-
'workbench.randomInspiration': '
|
| 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': '
|
| 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 |
-
|
| 1169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
if (!response.ok) {
|
| 49 |
-
throw new Error(
|
| 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 === '
|
| 7 |
-
return t('streaming.
|
|
|
|
| 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
|
| 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
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|