mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-31 13:41:18 +08:00
155 lines
7.3 KiB
TypeScript
155 lines
7.3 KiB
TypeScript
import axios from "axios";
|
||
|
||
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
|
||
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
|
||
import { buildApiUrl, resolveModelRequestConfig, resolveModelScript, type AiConfig } from "@/stores/use-config-store";
|
||
import { runModelPlugin } from "./model-plugin";
|
||
|
||
type RequestOptions = { signal?: AbortSignal };
|
||
|
||
function aiApiUrl(config: AiConfig, path: string) {
|
||
return buildApiUrl(config.baseUrl, path);
|
||
}
|
||
|
||
function aiHeaders(config: AiConfig) {
|
||
return {
|
||
Authorization: `Bearer ${config.apiKey}`,
|
||
"Content-Type": "application/json",
|
||
};
|
||
}
|
||
|
||
export async function requestAudioGeneration(config: AiConfig, prompt: string, options?: RequestOptions): Promise<Blob> {
|
||
const requestConfig = resolveModelRequestConfig(config, config.model || config.audioModel);
|
||
const model = requestConfig.model.trim();
|
||
const format = normalizeAudioFormatValue(config.audioFormat);
|
||
const script = resolveModelScript(config, config.model || config.audioModel);
|
||
if (script) {
|
||
if (!model) throw new Error("请先配置音频模型");
|
||
if (!requestConfig.baseUrl.trim()) throw new Error("请先配置 Base URL");
|
||
if (!requestConfig.apiKey.trim()) throw new Error("请先配置 API Key");
|
||
try {
|
||
const result = await runModelPlugin({
|
||
capability: "audio",
|
||
script,
|
||
config: requestConfig,
|
||
prompt,
|
||
params: { voice: normalizeAudioVoiceValue(config.audioVoice), format, speed: normalizeAudioSpeedValue(config.audioSpeed), instructions: config.audioInstructions.trim() },
|
||
signal: options?.signal,
|
||
});
|
||
return await audioPluginBlob(result, format);
|
||
} catch (error) {
|
||
throw new Error(readAxiosError(error, "音频生成失败"));
|
||
}
|
||
}
|
||
assertAudioConfig(requestConfig, model);
|
||
const instructions = config.audioInstructions.trim();
|
||
|
||
try {
|
||
const response = await axios.post<Blob>(
|
||
aiApiUrl(requestConfig, "/audio/speech"),
|
||
{
|
||
model,
|
||
input: prompt,
|
||
voice: normalizeAudioVoiceValue(config.audioVoice),
|
||
response_format: format,
|
||
speed: Number(normalizeAudioSpeedValue(config.audioSpeed)),
|
||
...(instructions ? { instructions } : {}),
|
||
},
|
||
{ headers: aiHeaders(requestConfig), responseType: "blob", signal: options?.signal },
|
||
);
|
||
await assertAudioBlob(response.data);
|
||
return response.data.type.startsWith("audio/") ? response.data : new Blob([response.data], { type: audioMimeType(format) });
|
||
} catch (error) {
|
||
throw new Error(readAxiosError(error, "音频生成失败"));
|
||
}
|
||
}
|
||
|
||
async function audioPluginBlob(result: unknown, format: string): Promise<Blob> {
|
||
if (result instanceof Blob) return result.type.startsWith("audio/") ? result : new Blob([result], { type: audioMimeType(format) });
|
||
let source = "";
|
||
if (typeof result === "string") source = result;
|
||
else if (result && typeof result === "object") {
|
||
const record = result as Record<string, unknown>;
|
||
source = typeof record.b64_json === "string" ? record.b64_json : typeof record.data === "string" ? record.data : typeof record.url === "string" ? record.url : "";
|
||
}
|
||
if (!source) throw new Error("模型调用脚本没有返回音频");
|
||
const url = source.startsWith("data:") || /^https?:/i.test(source) ? source : `data:${audioMimeType(format)};base64,${source}`;
|
||
const blob = await (await fetch(url)).blob();
|
||
return blob.type.startsWith("audio/") ? blob : new Blob([blob], { type: audioMimeType(format) });
|
||
}
|
||
|
||
export async function storeGeneratedAudio(blob: Blob, format = "mp3"): Promise<UploadedFile> {
|
||
const audio = blob.type.startsWith("audio/") ? blob : new Blob([blob], { type: audioMimeType(format) });
|
||
return uploadMediaFile(audio, "audio");
|
||
}
|
||
|
||
function assertAudioConfig(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 格式渠道");
|
||
}
|
||
|
||
async function assertAudioBlob(blob: Blob) {
|
||
if (!blob.type.includes("json")) return;
|
||
let payload: { code?: number; msg?: string; error?: { message?: string } };
|
||
try {
|
||
payload = JSON.parse(await blob.text()) as { code?: number; msg?: string; error?: { message?: string } };
|
||
} catch {
|
||
return;
|
||
}
|
||
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || "音频生成失败");
|
||
if (payload.error?.message) throw new Error(payload.error.message);
|
||
}
|
||
|
||
function readApiErrorMessage(value: unknown): string {
|
||
if (!value) return "";
|
||
if (typeof value === "string") {
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
const inner = readApiErrorMessage(parsed) || value;
|
||
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)}...)`;
|
||
return value;
|
||
}
|
||
}
|
||
if (typeof value !== "object") return "";
|
||
const payload = value as { msg?: unknown; message?: unknown; error?: unknown; detail?: unknown };
|
||
const errorMsg =
|
||
typeof payload.error === "string"
|
||
? payload.error
|
||
: (payload.error as { message?: unknown })?.message;
|
||
return (
|
||
readApiErrorMessage(payload.msg) ||
|
||
readApiErrorMessage(payload.message) ||
|
||
readApiErrorMessage(errorMsg) ||
|
||
readApiErrorMessage(payload.detail) ||
|
||
""
|
||
);
|
||
}
|
||
|
||
function readAxiosError(error: unknown, fallback: string) {
|
||
if (axios.isCancel(error)) return "请求已取消";
|
||
if (axios.isAxiosError(error)) {
|
||
const responseData = error.response?.data;
|
||
const apiMsg = readApiErrorMessage(responseData);
|
||
if (apiMsg) return apiMsg;
|
||
const statusMsg = statusMessage(error.response?.status, fallback);
|
||
if (statusMsg) return statusMsg;
|
||
return error.message || fallback;
|
||
}
|
||
if (error instanceof DOMException && error.name === "AbortError") return "请求已取消";
|
||
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 === 404) return "接口地址不存在(404),请检查 Base URL 和模型选择";
|
||
if (status === 502) return "网关错误(502),接口服务暂时不可用,请稍后重试";
|
||
if (status === 503) return "服务繁忙(503),请稍后重试";
|
||
return status ? `请求失败(HTTP ${status}),请检查 Base URL 和 API Key 是否正确` : fallback;
|
||
}
|