Enhance credential management and testing functionality
- Introduce new fields for voice, speed, and language in the AssistantConfig and ProviderCredential models to support TTS and ASR configurations. - Update the database schema and seeding script to accommodate the new fields, ensuring backward compatibility. - Implement credential testing endpoints and logic to validate OpenAI-compatible credentials, enhancing user experience and reliability. - Modify frontend components to include new fields in the credential forms and improve connection testing feedback. - Refactor related services and API interactions to support the new credential testing feature.
This commit is contained in:
@@ -77,6 +77,9 @@ type ModelForm = {
|
||||
interfaceType: InterfaceType;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
voice: string;
|
||||
speed: number;
|
||||
language: string;
|
||||
};
|
||||
|
||||
const emptyForm: ModelForm = {
|
||||
@@ -86,6 +89,9 @@ const emptyForm: ModelForm = {
|
||||
interfaceType: "openai",
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
voice: "",
|
||||
speed: 1,
|
||||
language: "",
|
||||
};
|
||||
|
||||
type TypeFilter = "全部" | ModelType;
|
||||
@@ -114,6 +120,7 @@ export function ComponentsModelsPage() {
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<"idle" | "ok" | "fail">("idle");
|
||||
const [testMessage, setTestMessage] = useState("");
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -137,20 +144,40 @@ export function ComponentsModelsPage() {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
// 任何配置变更后,旧的测试结果不再可信,重置为待测状态
|
||||
setTestResult("idle");
|
||||
setTestMessage("");
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
setTesting(true);
|
||||
setTestResult("idle");
|
||||
setTestMessage("");
|
||||
try {
|
||||
// TODO: 接入真实疎通接口(按 form.interfaceType 区分调用方式)
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
const reachable = Boolean(
|
||||
form.apiUrl.trim() && (form.apiKey.trim() || editingModel?.apiKey),
|
||||
const result = await credentialsApi.test(
|
||||
{
|
||||
modelId: form.modelId.trim(),
|
||||
type: form.type,
|
||||
interfaceType: form.interfaceType,
|
||||
apiUrl: form.apiUrl.trim(),
|
||||
apiKey: form.apiKey,
|
||||
voice: form.voice.trim(),
|
||||
speed: form.speed,
|
||||
language: form.language.trim(),
|
||||
},
|
||||
editingId ?? undefined,
|
||||
);
|
||||
setTestResult(reachable ? "ok" : "fail");
|
||||
} catch {
|
||||
setTestResult(result.ok ? "ok" : "fail");
|
||||
setTestMessage(
|
||||
[
|
||||
result.message,
|
||||
result.latencyMs !== null ? `${result.latencyMs}ms` : "",
|
||||
result.detail,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
);
|
||||
} catch (error) {
|
||||
setTestResult("fail");
|
||||
setTestMessage(error instanceof Error ? error.message : "测试连接失败");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
@@ -165,8 +192,12 @@ export function ComponentsModelsPage() {
|
||||
interfaceType: options.includes(prev.interfaceType)
|
||||
? prev.interfaceType
|
||||
: options[0],
|
||||
voice: type === "TTS" ? prev.voice : "",
|
||||
speed: type === "TTS" ? prev.speed : 1,
|
||||
language: type === "ASR" ? prev.language : "",
|
||||
}));
|
||||
setTestResult("idle");
|
||||
setTestMessage("");
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
@@ -175,6 +206,7 @@ export function ComponentsModelsPage() {
|
||||
setForm(emptyForm);
|
||||
setShowKey(false);
|
||||
setTestResult("idle");
|
||||
setTestMessage("");
|
||||
setSaveError(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
@@ -190,9 +222,13 @@ export function ComponentsModelsPage() {
|
||||
apiUrl: model.apiUrl,
|
||||
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
|
||||
apiKey: "",
|
||||
voice: model.voice,
|
||||
speed: model.speed,
|
||||
language: model.language,
|
||||
});
|
||||
setShowKey(false);
|
||||
setTestResult("idle");
|
||||
setTestMessage("");
|
||||
setSaveError(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
@@ -207,6 +243,9 @@ export function ComponentsModelsPage() {
|
||||
interfaceType: form.interfaceType,
|
||||
apiUrl: form.apiUrl.trim(),
|
||||
apiKey: form.apiKey,
|
||||
voice: form.voice.trim(),
|
||||
speed: form.speed,
|
||||
language: form.language.trim(),
|
||||
// 表单未暴露 isDefault,编辑时沿用原值,新建默认 false
|
||||
isDefault: editingModel?.isDefault ?? false,
|
||||
};
|
||||
@@ -285,8 +324,19 @@ export function ComponentsModelsPage() {
|
||||
|
||||
const interfaceOptions = interfaceOptionsByType[form.type];
|
||||
const hasStoredApiKey = Boolean(editingId && editingModel?.apiKey);
|
||||
const hasRequiredTypeOptions =
|
||||
form.type !== "TTS" || Boolean(form.voice.trim() && form.speed > 0);
|
||||
const canSave =
|
||||
form.name.trim() && form.modelId.trim() && form.apiUrl.trim();
|
||||
form.name.trim() &&
|
||||
form.modelId.trim() &&
|
||||
form.apiUrl.trim() &&
|
||||
hasRequiredTypeOptions;
|
||||
const canTest = Boolean(
|
||||
form.modelId.trim() &&
|
||||
form.apiUrl.trim() &&
|
||||
hasRequiredTypeOptions &&
|
||||
(form.apiKey.trim() || editingModel?.apiKey),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
|
||||
@@ -685,6 +735,75 @@ export function ComponentsModelsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.type === "TTS" && (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<div className="block">
|
||||
<FieldLabel
|
||||
htmlFor="model-voice"
|
||||
hint={{
|
||||
description:
|
||||
"调用语音合成接口时使用的音色标识,需与服务商支持的 voice 一致。",
|
||||
example: "alloy、中文女",
|
||||
}}
|
||||
>
|
||||
Voice
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="model-voice"
|
||||
value={form.voice}
|
||||
onChange={(event) => updateForm("voice", event.target.value)}
|
||||
placeholder="例如 alloy"
|
||||
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||
/>
|
||||
</div>
|
||||
<div className="block">
|
||||
<FieldLabel
|
||||
htmlFor="model-speed"
|
||||
hint={{
|
||||
description: "语音合成的播放速度,1 表示正常速度。",
|
||||
example: "0.8、1、1.2",
|
||||
}}
|
||||
>
|
||||
Speed
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="model-speed"
|
||||
type="number"
|
||||
min="0.25"
|
||||
max="4"
|
||||
step="0.1"
|
||||
value={form.speed}
|
||||
onChange={(event) =>
|
||||
updateForm("speed", Number(event.target.value) || 1)
|
||||
}
|
||||
className="border-hairline-strong bg-background text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.type === "ASR" && (
|
||||
<div className="block">
|
||||
<FieldLabel
|
||||
htmlFor="model-language"
|
||||
hint={{
|
||||
description:
|
||||
"语音识别的语言代码,留空时由模型自动判断或使用服务商默认值。",
|
||||
example: "zh、en",
|
||||
}}
|
||||
>
|
||||
Language
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="model-language"
|
||||
value={form.language}
|
||||
onChange={(event) => updateForm("language", event.target.value)}
|
||||
placeholder="例如 zh,留空则自动识别"
|
||||
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="block">
|
||||
<FieldLabel
|
||||
htmlFor="model-api-key"
|
||||
@@ -742,7 +861,7 @@ export function ComponentsModelsPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
disabled={!canSave || testing}
|
||||
disabled={!canTest || testing}
|
||||
onClick={handleTestConnection}
|
||||
>
|
||||
{testing ? (
|
||||
@@ -755,13 +874,13 @@ export function ComponentsModelsPage() {
|
||||
{testResult === "ok" && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-emerald-500">
|
||||
<CheckCircle2 size={14} />
|
||||
连接成功
|
||||
{testMessage}
|
||||
</span>
|
||||
)}
|
||||
{testResult === "fail" && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-destructive">
|
||||
<XCircle size={14} />
|
||||
连接失败
|
||||
{testMessage}
|
||||
</span>
|
||||
)}
|
||||
{saveError && (
|
||||
|
||||
Reference in New Issue
Block a user