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
+42 -40
View File
@@ -1,6 +1,7 @@
import axios from "axios";
import { nanoid } from "nanoid";
import i18n from "@/i18n";
import { dataUrlToFile } from "@/lib/image-utils";
import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { imageToDataUrl } from "@/services/image-storage";
@@ -23,6 +24,7 @@ type SeedanceTask = {
};
type ApiEnvelope<T> = T | { code?: number | string; data?: T | null; msg?: string; message?: string; error?: { message?: string } };
type RequestOptions = { signal?: AbortSignal };
const apiText = (key: string, options?: Record<string, unknown>) => i18n.t(`apiErrors.${key}`, options);
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance" | "plugin"; model: string };
@@ -50,10 +52,10 @@ export async function requestVideoGeneration(config: AiConfig, prompt: string, r
const state = await pollVideoGenerationTask(config, task, options);
if (state.status === "completed") return state.result;
if (state.status === "failed") throw new Error(state.error);
if (attempt === 119) throw new Error(`${task.provider === "seedance" ? "Seedance " : ""}视频生成超时,请稍后重试`);
if (attempt === 119) throw new Error(apiText("videoTimeout", { provider: task.provider === "seedance" ? "Seedance " : "" }));
await delay(delayMs, options?.signal);
}
throw new Error("视频生成超时,请稍后重试");
throw new Error(apiText("videoTimeout", { provider: "" }));
}
export async function createVideoGenerationTask(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = [], options?: RequestOptions): Promise<VideoGenerationTask> {
@@ -66,7 +68,7 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string
return createSeedanceTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
}
if (videoReferences.length || audioReferences.length) {
throw new Error("当前视频接口不支持参考视频或参考音频,请切换到 Seedance 2.0 / 火山 Agent Plan 模型,或移除参考资产");
throw new Error(apiText("videoReferencesUnsupported"));
}
return createOpenAIVideoTask(requestConfig, selectedModel, prompt, references, options);
}
@@ -74,7 +76,7 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string
export async function pollVideoGenerationTask(config: AiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
if (task.provider === "plugin") {
const result = pluginVideoResults.get(task.id);
return result ? { status: "completed", result } : { status: "failed", error: "插件视频任务已失效,请重新生成" };
return result ? { status: "completed", result } : { status: "failed", error: apiText("pluginVideoExpired") };
}
const requestConfig = resolveModelRequestConfig(config, task.model);
assertVideoConfig(requestConfig, requestConfig.model);
@@ -82,8 +84,8 @@ export async function pollVideoGenerationTask(config: AiConfig, task: VideoGener
}
async function createPluginVideoTask(config: AiConfig, model: string, script: string, prompt: string, references: ReferenceImage[], options?: RequestOptions): Promise<VideoGenerationTask> {
if (!config.baseUrl.trim()) throw new Error("请先配置 Base URL");
if (!config.apiKey.trim()) throw new Error("请先配置 API Key");
if (!config.baseUrl.trim()) throw new Error(apiText("baseUrlRequired"));
if (!config.apiKey.trim()) throw new Error(apiText("apiKeyRequired"));
const refs = await Promise.all(references.map((image) => imageToDataUrl(image)));
const result = videoPluginResult(
await runModelPlugin({
@@ -117,7 +119,7 @@ function videoPluginResult(result: unknown): VideoGenerationResult {
const url = [record.url, record.video_url, record.result_url].find((value) => typeof value === "string" && value) as string | undefined;
if (url) return { url, mimeType: "video/mp4" };
}
throw new Error("模型调用脚本没有返回视频");
throw new Error(apiText("scriptNoVideo"));
}
export async function storeGeneratedVideo(result: VideoGenerationResult): Promise<UploadedFile> {
@@ -129,7 +131,7 @@ export async function storeGeneratedVideo(result: VideoGenerationResult): Promis
return { url: result.url, storageKey: "", bytes: 0, mimeType: result.mimeType || "video/mp4" };
}
}
throw new Error("视频接口没有返回可播放的视频");
throw new Error(apiText("noPlayableVideo"));
}
async function createOpenAIVideoTask(config: AiConfig, model: string, prompt: string, references: ReferenceImage[], options?: RequestOptions): Promise<VideoGenerationTask> {
@@ -144,10 +146,10 @@ async function createOpenAIVideoTask(config: AiConfig, model: string, prompt: st
files.forEach((file) => body.append("input_reference[]", file));
try {
const created = unwrapVideoResponse((await axios.post<ApiVideoResponse>(aiApiUrl(config, "/videos"), body, { headers: aiHeaders(config), signal: options?.signal })).data);
if (!created.id) throw new Error("视频接口没有返回任务 ID");
if (!created.id) throw new Error(apiText("noVideoTaskId"));
return { id: created.id, provider: "openai", model };
} catch (error) {
throw new Error(readAxiosError(error, "视频任务创建失败"));
throw new Error(readAxiosError(error, apiText("videoTaskCreateFailed")));
}
}
@@ -161,21 +163,21 @@ async function pollOpenAIVideoTask(config: AiConfig, task: VideoGenerationTask,
await assertVideoBlob(content.data);
return { status: "completed", result: { blob: content.data } };
}
if (video.status === "failed" || video.status === "cancelled") return { status: "failed", error: readApiErrorMessage(video.error?.message) || "视频生成失败" };
if (video.status === "failed" || video.status === "cancelled") return { status: "failed", error: readApiErrorMessage(video.error?.message) || apiText("videoGenerationFailed") };
return { status: "pending" };
} catch (error) {
throw new Error(readAxiosError(error, "视频任务查询失败"));
throw new Error(readAxiosError(error, apiText("videoTaskQueryFailed")));
}
}
async function createSeedanceTask(config: AiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], options?: RequestOptions): Promise<VideoGenerationTask> {
if (audioReferences.length && !references.length && !videoReferences.length) {
throw new Error("Seedance 参考音频不能单独使用,请同时添加参考图或参考视频");
throw new Error(apiText("seedanceAudioRequiresVisual"));
}
assertSeedanceVideoReferences(videoReferences);
assertSeedanceAudioReferences(audioReferences);
const content = await buildSeedanceContent(config, prompt, references, videoReferences, audioReferences);
if (!content.length) throw new Error("请输入视频提示词,或连接参考图片/视频/音频");
if (!content.length) throw new Error(apiText("videoPromptRequired"));
const payload = {
model: modelOptionName(model),
content,
@@ -188,10 +190,10 @@ async function createSeedanceTask(config: AiConfig, model: string, prompt: strin
try {
const created = unwrapSeedanceTask((await axios.post<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config), payload, { headers: aiHeaders(config, "application/json"), signal: options?.signal })).data);
if (!created.id) throw new Error("Seedance 接口没有返回任务 ID");
if (!created.id) throw new Error(apiText("seedanceNoTaskId"));
return { id: created.id, provider: "seedance", model };
} catch (error) {
throw new Error(readAxiosError(error, "Seedance 任务创建失败"));
throw new Error(readAxiosError(error, apiText("seedanceTaskCreateFailed")));
}
}
@@ -200,11 +202,11 @@ async function pollSeedanceTask(config: AiConfig, task: VideoGenerationTask, opt
const state = unwrapSeedanceTask((await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), signal: options?.signal })).data);
const url = videoResultUrl(state);
if (url) return { status: "completed", result: await videoResultFromUrl(url, options) };
if (state.status === "succeeded" || state.status === "completed") return { status: "failed", error: "Seedance 任务成功但没有返回视频 URL" };
if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: readApiErrorMessage(state.error?.message) || `Seedance 视频生成${state.status === "expired" ? "超时" : "失败"}` };
if (state.status === "succeeded" || state.status === "completed") return { status: "failed", error: apiText("seedanceNoVideoUrl") };
if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: readApiErrorMessage(state.error?.message) || apiText(state.status === "expired" ? "seedanceVideoTimeout" : "seedanceVideoFailed") };
return { status: "pending" };
} catch (error) {
throw new Error(readAxiosError(error, "Seedance 任务查询失败"));
throw new Error(readAxiosError(error, apiText("seedanceTaskQueryFailed")));
}
}
@@ -214,20 +216,20 @@ function assertSeedanceVideoReferences(videoReferences: ReferenceVideo[]) {
let total = 0;
for (const video of videoReferences) {
if (!video.durationMs) continue;
if (video.durationMs < 2000 || video.durationMs > 15000) throw new Error("Seedance 参考视频单个时长需要在 2-15 秒之间");
if (video.durationMs < 2000 || video.durationMs > 15000) throw new Error(apiText("seedanceVideoDuration"));
total += video.durationMs;
}
if (total > 15000) throw new Error("Seedance 参考视频总时长不能超过 15 秒");
if (total > 15000) throw new Error(apiText("seedanceVideoTotalDuration"));
}
function assertSeedanceAudioReferences(audioReferences: ReferenceAudio[]) {
let total = 0;
for (const audio of audioReferences) {
if (!audio.durationMs) continue;
if (audio.durationMs < 2000 || audio.durationMs > 15000) throw new Error("Seedance 参考音频单个时长需要在 2-15 秒之间");
if (audio.durationMs < 2000 || audio.durationMs > 15000) throw new Error(apiText("seedanceAudioDuration"));
total += audio.durationMs;
}
if (total > 15000) throw new Error("Seedance 参考音频总时长不能超过 15 秒");
if (total > 15000) throw new Error(apiText("seedanceAudioTotalDuration"));
}
function seedanceApiUrl(config: AiConfig, taskId?: string) {
@@ -254,7 +256,7 @@ async function resolveSeedanceImageUrl(config: AiConfig, image: ReferenceImage)
const directUrl = image.url || image.dataUrl;
if (isPublicMediaUrl(directUrl) || directUrl.startsWith("asset://")) return directUrl;
const dataUrl = await imageToDataUrl(image);
if (!dataUrl) throw new Error("参考图读取失败,请换一张图片或重新上传");
if (!dataUrl) throw new Error(apiText("referenceImageReadFailed"));
return dataUrl;
}
@@ -263,7 +265,7 @@ async function resolveSeedanceVideoUrl(video: ReferenceVideo) {
let blob: Blob | null = null;
if (video.storageKey) blob = await getMediaBlob(video.storageKey);
if (!blob && video.url?.startsWith("blob:")) blob = await (await fetch(video.url)).blob();
if (!blob) throw new Error("参考视频必须是公网 URL、资产 ID,或本地已保存的视频");
if (!blob) throw new Error(apiText("invalidReferenceVideo"));
return blobToDataUrl(blob);
}
@@ -272,7 +274,7 @@ async function resolveSeedanceAudioUrl(audio: ReferenceAudio) {
let blob: Blob | null = null;
if (audio.storageKey) blob = await getMediaBlob(audio.storageKey);
if (!blob && audio.url?.startsWith("blob:")) blob = await (await fetch(audio.url)).blob();
if (!blob) throw new Error("参考音频必须是公网 URL、资产 ID,或本地已保存的音频");
if (!blob) throw new Error(apiText("invalidReferenceAudio"));
return blobToDataUrl(blob);
}
@@ -288,10 +290,10 @@ async function videoResultFromUrl(url: string, options?: RequestOptions): Promis
}
function assertVideoConfig(config: AiConfig, model: string) {
if (!model) throw new Error("请先配置视频模型");
if (!config.baseUrl.trim()) throw new Error("请先配置 Base URL");
if (!config.apiKey.trim()) throw new Error("请先配置 API Key");
if (config.apiFormat === "gemini") throw new Error("Gemini 调用格式暂不支持视频生成,请使用 OpenAI 格式渠道");
if (!model) throw new Error(apiText("videoModelRequired"));
if (!config.baseUrl.trim()) throw new Error(apiText("baseUrlRequired"));
if (!config.apiKey.trim()) throw new Error(apiText("apiKeyRequired"));
if (config.apiFormat === "gemini") throw new Error(apiText("geminiVideoUnsupported"));
}
function normalizeVideoSeconds(value: string) {
@@ -314,17 +316,17 @@ function normalizeVideoResolution(value: string) {
}
function unwrapVideoResponse(payload: ApiVideoResponse) {
return unwrapEnvelope(payload, "接口没有返回视频任务");
return unwrapEnvelope(payload, apiText("noVideoTask"));
}
function unwrapSeedanceTask(payload: ApiEnvelope<SeedanceTask>) {
return unwrapEnvelope(payload, "Seedance 接口没有返回任务");
return unwrapEnvelope(payload, apiText("seedanceNoTask"));
}
function unwrapEnvelope<T>(payload: ApiEnvelope<T>, emptyMessage: string): T {
if (!payload) throw new Error(emptyMessage);
if (typeof payload === "object" && "code" in payload && payload.code !== undefined) {
if (payload.code !== 0 && payload.code !== "0") throw new Error(readApiErrorMessage(payload) || "请求失败");
if (payload.code !== 0 && payload.code !== "0") throw new Error(readApiErrorMessage(payload) || apiText("requestFailed"));
if (!payload.data) throw new Error(emptyMessage);
return payload.data;
}
@@ -344,7 +346,7 @@ function readApiErrorMessage(value: unknown): string {
if (inner === value && typeof parsed === "object" && Object.keys(parsed).length === 0) return "";
return inner;
} catch {
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;
}
}
@@ -365,18 +367,18 @@ 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?: { message?: string }; msg?: string; message?: string; code?: number | string }>(error)) {
const responseData = error.response?.data;
return readApiErrorMessage(responseData) || statusMessage(error.response?.status, 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 statusMessage(status: number | undefined, fallback: string) {
if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限";
if (status === 429) return "请求被限流或额度不足,请稍后重试";
if (status === 401 || status === 403) return apiText("authenticationFailed");
if (status === 429) return apiText("rateLimited");
return status ? `${fallback}(${status})` : fallback;
}
@@ -388,7 +390,7 @@ async function assertVideoBlob(blob: Blob) {
} catch {
return;
}
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(readApiErrorMessage(payload) || "视频下载失败");
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(readApiErrorMessage(payload) || apiText("videoDownloadFailed"));
if (payload.error?.message) throw new Error(readApiErrorMessage(payload.error.message) || payload.error.message);
}
@@ -418,7 +420,7 @@ function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取本地资产失败"));
reader.onerror = () => reject(new Error(apiText("localAssetReadFailed")));
reader.readAsDataURL(blob);
});
}