feat: add DashScope custom voice enrollment flow
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { modelResourcesApi } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function DashscopeVoice({ resourceId, apiKey, apiUrl, model, onApply }: {
|
||||
resourceId?: string; apiKey: string; apiUrl: string; model: string; onApply: (voice: string) => void;
|
||||
}) {
|
||||
const [audio, setAudio] = useState("");
|
||||
const [voiceId, setVoiceId] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
async function upload(file?: File) {
|
||||
setAudio("");
|
||||
setMessage("");
|
||||
if (!file) return;
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
if (!extension || !["wav", "mp3", "m4a"].includes(extension) || !file.size || file.size > 10 * 1024 * 1024) {
|
||||
setMessage("请选择不超过 10 MB 的 WAV、MP3 或 M4A 文件"); return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const encoded = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result).split(",")[1]);
|
||||
reader.onerror = () => reject(new Error("读取录音失败"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
setAudio(`data:audio/${extension === "mp3" ? "mpeg" : extension};base64,${encoded}`);
|
||||
} catch { setMessage("读取录音失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function run(action: "create_voice" | "query_voice") {
|
||||
setBusy(true); setMessage("");
|
||||
try {
|
||||
const result = await modelResourcesApi.voice({ resourceId, apiKey, apiUrl, model, action, audio: action === "create_voice" ? audio : undefined, voiceId });
|
||||
setVoiceId(result.voiceId); setStatus(result.status);
|
||||
setMessage(result.status === "OK" ? "音色已就绪,可以应用到当前配置" : result.status === "DEPLOYING" ? "音色处理中,请稍后查询状态" : "音色不可用,请检查录音要求");
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : "音色请求失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <div className="space-y-3 rounded-xl border border-border p-4">
|
||||
<div className="text-sm font-medium">自定义音色 · 声音复刻</div>
|
||||
<p className="text-xs text-muted-foreground">上传本人或已获授权的清晰录音,推荐 10–20 秒,最长 60 秒,采样率至少 16 kHz。音色仅用于当前模型;切换模型需重新创建。</p>
|
||||
<Input type="file" accept=".wav,.mp3,.m4a" disabled={busy} aria-label="参考录音" onChange={event => void upload(event.target.files?.[0])} />
|
||||
<Button type="button" variant="outline" disabled={busy || !audio || Boolean(voiceId)} onClick={() => void run("create_voice")}>创建音色</Button>
|
||||
<p className="text-xs text-muted-foreground">也可以粘贴已有音色 ID,查询就绪后应用。</p>
|
||||
<Input value={voiceId} disabled={busy} aria-label="自定义音色 ID" placeholder="音色 ID" onChange={event => { setVoiceId(event.target.value); setStatus(""); setMessage(""); }} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" disabled={busy || !voiceId} onClick={() => void run("query_voice")}>查询状态</Button>
|
||||
<Button type="button" disabled={busy || status !== "OK"} onClick={() => { onApply(voiceId); setMessage("已填入音色字段,请测试连接并保存模型资源"); }}>使用此音色</Button>
|
||||
</div>
|
||||
{message && <p role="status" className="text-xs text-muted-foreground">{message}</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { DashscopeVoice } from "@/components/models/DashscopeVoice";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -807,6 +808,19 @@ export function ComponentsModelsPage() {
|
||||
</FieldSection>
|
||||
|
||||
<FieldSection title="连接与鉴权信息" scrollable fill>
|
||||
{draft.interfaceType.startsWith("dashscope-") && (
|
||||
<a href="https://help.aliyun.com/zh/model-studio/get-api-key" target="_blank" rel="noopener noreferrer" className="text-xs text-primary underline underline-offset-4">获取百炼 API Key 与鉴权说明 ↗</a>
|
||||
)}
|
||||
{draft.interfaceType === "dashscope-tts" && (
|
||||
<DashscopeVoice
|
||||
key={`${editingId ?? "new"}:${draft.values.model}:${draft.values.apiUrl}`}
|
||||
resourceId={editingId ?? undefined}
|
||||
apiKey={String(draft.secrets.apiKey || storedSecretMasks.apiKey || "")}
|
||||
apiUrl={String(draft.values.apiUrl || "")}
|
||||
model={String(draft.values.model || "")}
|
||||
onApply={voice => { setDraft(previous => ({ ...previous, values: { ...previous.values, voice } })); setTestResult(null); }}
|
||||
/>
|
||||
)}
|
||||
{connectionFields.map((field) => (
|
||||
<DynamicField
|
||||
key={`${field.group}-${field.key}`}
|
||||
|
||||
@@ -229,6 +229,8 @@ export const interfaceDefinitionsApi = {
|
||||
};
|
||||
|
||||
export const modelResourcesApi = {
|
||||
voice: (body: { resourceId?: string; apiKey: string; apiUrl: string; model: string; action: "create_voice" | "query_voice"; audio?: string; voiceId?: string }) =>
|
||||
request<{ voiceId: string; status: string }>("/api/model-resources/dashscope/voice", { method: "POST", body: JSON.stringify(body) }),
|
||||
list: () => request<ModelResource[]>("/api/model-resources"),
|
||||
create: (body: ModelResourceUpsert) =>
|
||||
request<ModelResource>("/api/model-resources", {
|
||||
|
||||
Reference in New Issue
Block a user