feat(i18n): implement internationalization framework and update UI components for language support
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user