feat(i18n): implement internationalization framework and update UI components for language support

This commit is contained in:
HouYunFei
2026-08-05 14:08:03 +08:00
parent 9bccd0ff1a
commit 0f6809251a
116 changed files with 3269 additions and 1870 deletions
+47 -44
View File
@@ -1,5 +1,6 @@
import axios from "axios";
import i18n from "@/i18n";
import { buildApiUrl, resolveModelRequestConfig, resolveModelScript, type AiConfig, type ModelChannel } from "@/stores/use-config-store";
import { normalizePluginImages, runModelPlugin } from "./model-plugin";
import { nanoid } from "nanoid";
@@ -8,6 +9,8 @@ import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
import { imageToDataUrl } from "@/services/image-storage";
import type { ReferenceImage } from "@/types/image";
const apiText = (key: string, options?: Record<string, unknown>) => i18n.t(`apiErrors.${key}`, options);
export type AiTextMessage = {
role: "system" | "user" | "assistant";
content: string | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>;
@@ -154,16 +157,16 @@ function resolveSize(quality: string | undefined, ratio: string): string {
function parseRatioValue(value: string) {
const parts = value.split(":");
if (parts.length !== 2) throw new Error("图像尺寸格式不支持,请使用 auto、9:16 或 1024x1024");
if (parts.length !== 2) throw new Error(apiText("invalidImageSizeFormat"));
const w = Number(parts[0]);
const h = Number(parts[1]);
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) throw new Error("图像比例必须是正数,例如 9:16");
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) throw new Error(apiText("positiveImageRatio"));
return { width: w, height: h };
}
function parseImageRatio(value: string) {
const ratio = parseRatioValue(value);
if (Math.max(ratio.width, ratio.height) / Math.min(ratio.width, ratio.height) > IMAGE_MAX_RATIO) throw new Error("图像宽高比不能超过 3:1,请调整尺寸");
if (Math.max(ratio.width, ratio.height) / Math.min(ratio.width, ratio.height) > IMAGE_MAX_RATIO) throw new Error(apiText("imageRatioLimit"));
return ratio;
}
@@ -174,12 +177,12 @@ function parseImageDimensions(value: string) {
}
function validateImageSize(width: number, height: number) {
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) throw new Error("图像尺寸必须是正整数,例如 1024x1024");
if (width % IMAGE_SIZE_STEP !== 0 || height % IMAGE_SIZE_STEP !== 0) throw new Error("图像尺寸的宽高必须是 16 的倍数,请调整尺寸");
if (Math.max(width, height) > IMAGE_MAX_EDGE) throw new Error("图像尺寸最长边不能超过 3840px,请调整尺寸");
if (Math.max(width, height) / Math.min(width, height) > IMAGE_MAX_RATIO) throw new Error("图像宽高比不能超过 3:1,请调整尺寸");
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) throw new Error(apiText("positiveImageDimensions"));
if (width % IMAGE_SIZE_STEP !== 0 || height % IMAGE_SIZE_STEP !== 0) throw new Error(apiText("imageDimensionStep"));
if (Math.max(width, height) > IMAGE_MAX_EDGE) throw new Error(apiText("imageEdgeLimit"));
if (Math.max(width, height) / Math.min(width, height) > IMAGE_MAX_RATIO) throw new Error(apiText("imageRatioLimit"));
const pixels = width * height;
if (pixels < IMAGE_MIN_PIXELS || pixels > IMAGE_MAX_PIXELS) throw new Error("图像总像素需在 655360 到 8294400 之间,请调整尺寸");
if (pixels < IMAGE_MIN_PIXELS || pixels > IMAGE_MAX_PIXELS) throw new Error(apiText("imagePixelLimit"));
}
function resolveRequestSize(quality: string | undefined, size: string) {
@@ -191,7 +194,7 @@ function resolveRequestSize(quality: string | undefined, size: string) {
return `${dimensions.width}x${dimensions.height}`;
}
if (value.includes(":")) return resolveSize(quality, value);
throw new Error("图像尺寸格式不支持,请使用 auto、9:16 或 1024x1024");
throw new Error(apiText("invalidImageSizeFormat"));
}
function resolveGeminiImageConfig(config: AiConfig) {
@@ -242,7 +245,7 @@ function resolveImageDataUrl(item: Record<string, unknown>) {
function parseImagePayload(payload: ImageApiResponse) {
if (typeof payload.code === "number" && payload.code !== 0) {
throw new Error(payload.msg || "请求失败");
throw new Error(payload.msg || apiText("requestFailed"));
}
// 支持 data / images / results 三种返回字段(兼容不同 API)
const imageList = payload.data
@@ -259,8 +262,8 @@ function parseImagePayload(payload: ImageApiResponse) {
// 尝试检查是否有返回了但格式不被识别的数据
const rawKeys = Object.keys(payload).filter((k) => k !== "code" && k !== "msg" && k !== "error");
throw new Error(rawKeys.length > 0
? `接口返回了未知格式的数据(字段:${rawKeys.join("、")}),请检查模型或接口兼容性`
: "接口没有返回图片,请检查提示词是否触发安全审核或模型是否支持该操作");
? apiText("unknownImageResponse", { fields: rawKeys.join(", ") })
: apiText("noImageReturned"));
}
return images;
@@ -278,7 +281,7 @@ function readApiErrorMessage(value: unknown): string {
return inner;
} catch {
// 检查是否是 HTML 错误页面
if (/<[a-z][\s\S]*>/i.test(value)) return `服务返回了 HTML 错误页面(${value.slice(0, 80)}...)`;
if (/<[a-z][\s\S]*>/i.test(value)) return apiText("htmlError", { preview: `${value.slice(0, 80)}...` });
return value;
}
}
@@ -299,7 +302,7 @@ function readApiErrorMessage(value: unknown): string {
}
function readAxiosError(error: unknown, fallback: string) {
if (axios.isCancel(error)) return "请求已取消";
if (axios.isCancel(error)) return apiText("requestCanceled");
if (axios.isAxiosError(error)) {
const responseData = error.response?.data;
// 优先从响应体提取业务错误
@@ -311,17 +314,17 @@ function readAxiosError(error: unknown, fallback: string) {
// 最后用 axios 自身的错误文本
return error.message || fallback;
}
if (error instanceof DOMException && error.name === "AbortError") return "请求已取消";
if (error instanceof DOMException && error.name === "AbortError") return apiText("requestCanceled");
return error instanceof Error ? readApiErrorMessage(error.message) || error.message : fallback;
}
function readStatusError(status: number | undefined, fallback: string) {
if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限";
if (status === 429) return "请求被限流或额度不足,请稍后重试";
if (status === 404) return "接口地址不存在(404),请检查 Base URL 和模型选择";
if (status === 502) return "网关错误(502),接口服务暂时不可用,请稍后重试";
if (status === 503) return "服务繁忙(503),请稍后重试";
return status ? `请求失败(HTTP ${status}),请检查 Base URL 和 API Key 是否正确` : fallback;
if (status === 401 || status === 403) return apiText("authenticationFailed");
if (status === 429) return apiText("rateLimited");
if (status === 404) return apiText("notFound");
if (status === 502) return apiText("badGateway");
if (status === 503) return apiText("serviceBusy");
return status ? apiText("httpFailed", { status }) : fallback;
}
function withSystemPrompt(config: AiConfig, prompt: string) {
@@ -427,13 +430,13 @@ function stringValue(value: unknown) {
}
function validateResponsePayload(payload: ResponseApiPayload) {
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || "请求失败");
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || apiText("requestFailed"));
if (payload.error?.message) throw new Error(payload.error.message);
}
function validateGeminiPayload(payload: GeminiPayload) {
if (payload.error?.message) throw new Error(payload.error.message);
if (payload.promptFeedback?.blockReason) throw new Error(`Gemini 拒绝了本次请求:${payload.promptFeedback.blockReason}`);
if (payload.promptFeedback?.blockReason) throw new Error(apiText("geminiRejected", { reason: payload.promptFeedback.blockReason }));
}
async function readFetchError(response: Response, fallback: string) {
@@ -495,7 +498,7 @@ async function requestStreamingResponse(config: AiConfig, body: Record<string, u
body: JSON.stringify({ ...body, stream: true }),
signal: options?.signal,
});
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
if (!response.ok) throw new Error(await readFetchError(response, apiText("requestFailed")));
if (!response.body) {
const payload = (await response.json()) as ResponseApiPayload;
validateResponsePayload(payload);
@@ -602,7 +605,7 @@ async function requestGeminiStreamingResponse(config: AiConfig, body: Record<str
body: JSON.stringify(body),
signal: options?.signal,
});
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
if (!response.ok) throw new Error(await readFetchError(response, apiText("requestFailed")));
if (!response.body) {
const payload = (await response.json()) as GeminiPayload;
return parseGeminiToolResponse(payload);
@@ -706,7 +709,7 @@ function parseGeminiImagePayload(payload: GeminiPayload) {
})
.filter((value): value is string => Boolean(value))
.map((dataUrl) => ({ id: nanoid(), dataUrl })) || [];
if (!images.length) throw new Error("Gemini 接口没有返回图片");
if (!images.length) throw new Error(apiText("geminiNoImage"));
return images;
}
@@ -730,14 +733,14 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
});
return normalizePluginImages(result).map((dataUrl) => ({ id: nanoid(), dataUrl }));
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
throw new Error(readAxiosError(error, apiText("requestFailed")));
}
}
if (requestConfig.apiFormat === "gemini") {
try {
return await requestGeminiImages(requestConfig, prompt, [], n, options);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
throw new Error(readAxiosError(error, apiText("requestFailed")));
}
}
const quality = normalizeQuality(config.quality);
@@ -764,7 +767,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
const images = parseImagePayload(response.data);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
throw new Error(readAxiosError(error, apiText("requestFailed")));
}
}
@@ -790,20 +793,20 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
});
return normalizePluginImages(result).map((dataUrl) => ({ id: nanoid(), dataUrl }));
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
throw new Error(readAxiosError(error, apiText("requestFailed")));
}
}
if (requestConfig.apiFormat === "gemini") {
if (mask) throw new Error("Gemini 调用格式暂不支持蒙版编辑");
if (mask) throw new Error(apiText("geminiMaskUnsupported"));
try {
return await requestGeminiImages(requestConfig, requestPrompt, references, n, options);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
throw new Error(readAxiosError(error, apiText("requestFailed")));
}
}
if (requestConfig.apiFormat === "ark") {
if (mask) throw new Error("蒙版编辑暂不支持该模型,请使用其他渠道");
if (mask) throw new Error(apiText("maskModelUnsupported"));
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
const background = normalizeBackground(config.background);
@@ -829,7 +832,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
);
return parseImagePayload(response.data);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
throw new Error(readAxiosError(error, apiText("requestFailed")));
}
}
@@ -860,7 +863,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
const images = parseImagePayload(response.data);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
throw new Error(readAxiosError(error, apiText("requestFailed")));
}
}
@@ -877,28 +880,28 @@ export async function requestImageQuestion(config: AiConfig, messages: AiTextMes
signal: options?.signal,
onDelta,
});
const text = String(answer ?? "").trim() || "没有返回内容";
if (text === "没有返回内容") onDelta(text);
const text = String(answer ?? "").trim() || apiText("noContent");
if (text === apiText("noContent")) onDelta(text);
return text;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
throw new Error(readAxiosError(error, apiText("requestFailed")));
}
}
try {
if (requestConfig.apiFormat === "gemini") {
const answer = (await requestGeminiStreamingResponse(requestConfig, toGeminiBody(requestConfig, messages), onDelta, options)).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(answer);
const answer = (await requestGeminiStreamingResponse(requestConfig, toGeminiBody(requestConfig, messages), onDelta, options)).content || apiText("noContent");
if (answer === apiText("noContent")) onDelta(answer);
return answer;
}
const answer = (await requestStreamingResponse(requestConfig, {
model: requestConfig.model,
input: toResponseInput(withSystemMessage(requestConfig, messages)),
...(requestConfig.reasoningEffort === "auto" ? {} : { reasoning: { effort: requestConfig.reasoningEffort } }),
}, onDelta, options)).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(answer);
}, onDelta, options)).content || apiText("noContent");
if (answer === apiText("noContent")) onDelta(answer);
return answer;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
throw new Error(readAxiosError(error, apiText("requestFailed")));
}
}
@@ -922,7 +925,7 @@ export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKe
.filter((id): id is string => Boolean(id))
.sort((a, b) => a.localeCompare(b));
} catch (error) {
throw new Error(readAxiosError(error, "读取模型失败"));
throw new Error(readAxiosError(error, apiText("modelReadFailed")));
}
}