feat(config): enhance model configuration with support for audio models and improve UI for user preferences

This commit is contained in:
HouYunFei
2026-06-02 17:24:27 +08:00
parent 39793e890d
commit 38ec654748
4 changed files with 151 additions and 34 deletions
+84 -21
View File
@@ -1,11 +1,26 @@
"use client";
import { App, Button, Form, Input, Modal, Segmented } from "antd";
import { App, Button, Form, Input, Modal, Segmented, Select } from "antd";
import { useState } from "react";
import { ModelPicker } from "@/components/model-picker";
import { fetchImageModels } from "@/services/api/image";
import { filterModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { filterModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
type ModelGroup = {
capability: ModelCapability;
modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel";
modelsKey: "imageModels" | "videoModels" | "textModels" | "audioModels";
defaultLabel: string;
optionsLabel: string;
};
const modelGroups: ModelGroup[] = [
{ capability: "image", modelKey: "imageModel", modelsKey: "imageModels", defaultLabel: "默认生图模型", optionsLabel: "生图模型可选项" },
{ capability: "video", modelKey: "videoModel", modelsKey: "videoModels", defaultLabel: "默认视频模型", optionsLabel: "视频模型可选项" },
{ capability: "text", modelKey: "textModel", modelsKey: "textModels", defaultLabel: "默认文本模型", optionsLabel: "文本模型可选项" },
{ capability: "audio", modelKey: "audioModel", modelsKey: "audioModels", defaultLabel: "默认音频模型", optionsLabel: "音频模型可选项" },
];
export function AppConfigModal() {
const { message } = App.useApp();
@@ -22,6 +37,7 @@ export function AppConfigModal() {
const allowCustomChannel = modelChannel?.allowCustomChannel === true;
const effectiveMode = allowCustomChannel ? config.channelMode : "remote";
const modelConfig = effectiveMode === "remote" ? effectiveConfig : config;
const modelOptions = config.models.map((model) => ({ label: model, value: model }));
const finishConfig = () => {
setConfigDialogOpen(false);
@@ -44,10 +60,20 @@ export function AppConfigModal() {
const imageModels = filterModelsByCapability(models, "image");
const videoModels = filterModelsByCapability(models, "video");
const textModels = filterModelsByCapability(models, "text");
const audioModels = filterModelsByCapability(models, "audio");
const nextImageModels = resolveNextCapabilityModels(config.imageModels, imageModels, models);
const nextVideoModels = resolveNextCapabilityModels(config.videoModels, videoModels, models);
const nextTextModels = resolveNextCapabilityModels(config.textModels, textModels, models);
const nextAudioModels = resolveNextCapabilityModels(config.audioModels, audioModels, models);
updateConfig("models", models);
if (imageModels.length && !imageModels.includes(config.imageModel)) updateConfig("imageModel", imageModels[0]);
if (videoModels.length && !videoModels.includes(config.videoModel)) updateConfig("videoModel", videoModels[0]);
if (textModels.length && !textModels.includes(config.textModel)) updateConfig("textModel", textModels[0]);
updateConfig("imageModels", nextImageModels);
updateConfig("videoModels", nextVideoModels);
updateConfig("textModels", nextTextModels);
updateConfig("audioModels", nextAudioModels);
if (nextImageModels.length && !nextImageModels.includes(config.imageModel)) updateConfig("imageModel", nextImageModels[0]);
if (nextVideoModels.length && !nextVideoModels.includes(config.videoModel)) updateConfig("videoModel", nextVideoModels[0]);
if (nextTextModels.length && !nextTextModels.includes(config.textModel)) updateConfig("textModel", nextTextModels[0]);
if (nextAudioModels.length && !nextAudioModels.includes(config.audioModel)) updateConfig("audioModel", nextAudioModels[0]);
message.success("模型列表已更新");
} catch (error) {
message.error(error instanceof Error ? error.message : "读取模型失败");
@@ -56,18 +82,25 @@ export function AppConfigModal() {
}
};
const updateCapabilityModels = (group: ModelGroup, models: string[]) => {
const next = uniqueModels(models);
updateConfig(group.modelsKey, next);
if (!next.includes(config[group.modelKey])) updateConfig(group.modelKey, next[0] || "");
};
return (
<Modal
title={
<div>
<div className="text-lg font-semibold"></div>
<div className="mt-1 text-xs font-normal text-stone-500"></div>
<div className="text-lg font-semibold"></div>
<div className="mt-1 text-xs font-normal text-stone-500"></div>
</div>
}
open={isConfigOpen}
width={760}
width={960}
centered
onCancel={() => setConfigDialogOpen(false)}
styles={{ body: { maxHeight: "72vh", overflowY: "auto", paddingRight: 18 } }}
footer={
<Button type="primary" onClick={finishConfig}>
@@ -77,7 +110,7 @@ export function AppConfigModal() {
<div className="pt-1">
<Form layout="vertical" requiredMark={false}>
{allowCustomChannel ? (
<Form.Item label="渠道模式" className="mb-4">
<Form.Item label="渠道模式" className="mb-5">
<Segmented
block
size="middle"
@@ -100,7 +133,7 @@ export function AppConfigModal() {
<Input.Password value={config.apiKey} onChange={(event) => updateConfig("apiKey", event.target.value)} />
</Form.Item>
</div>
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 dark:border-stone-800">
<div className="mb-5 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 dark:border-stone-800">
<div className="min-w-0">
<div className="text-sm font-medium"></div>
<div className="mt-1 text-xs text-stone-500"> {config.models.length} </div>
@@ -111,21 +144,41 @@ export function AppConfigModal() {
</div>
</>
) : (
<div className="mb-4 rounded-lg border border-stone-200 p-3 text-sm text-stone-500 dark:border-stone-800">
<div className="mb-5 rounded-lg border border-stone-200 p-3 text-sm text-stone-500 dark:border-stone-800">
<div className="font-medium text-stone-900 dark:text-stone-100"></div>
<div className="mt-1"> {modelChannel?.availableModels.length || 0} </div>
</div>
)}
<div className="grid gap-4 md:grid-cols-3">
<Form.Item label="默认生图模型" className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig.imageModel} onChange={(model) => updateConfig("imageModel", model)} capability="image" fullWidth />
</Form.Item>
<Form.Item label="默认视频模型" className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig.videoModel} onChange={(model) => updateConfig("videoModel", model)} capability="video" fullWidth />
</Form.Item>
<Form.Item label="默认文本模型" className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig.textModel} onChange={(model) => updateConfig("textModel", model)} capability="text" fullWidth />
</Form.Item>
{effectiveMode === "local" ? (
<section className="mb-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="mb-3">
<div className="text-sm font-semibold"></div>
<div className="mt-1 text-xs text-stone-500"></div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{modelGroups.map((group) => (
<Form.Item key={group.modelsKey} label={group.optionsLabel} className="mb-0">
<Select
mode="multiple"
showSearch
allowClear
maxTagCount="responsive"
placeholder={config.models.length ? `请选择${group.optionsLabel}` : "请先拉取模型列表"}
value={config[group.modelsKey]}
options={modelOptions}
onChange={(models) => updateCapabilityModels(group, models)}
/>
</Form.Item>
))}
</div>
</section>
) : null}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{modelGroups.map((group) => (
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
</Form.Item>
))}
</div>
<div className="grid gap-4 md:grid-cols-3">
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
@@ -153,3 +206,13 @@ export function AppConfigModal() {
function normalizeImageCount(value: string) {
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3))));
}
function resolveNextCapabilityModels(current: string[], suggested: string[], allModels: string[]) {
const available = new Set(allModels);
const kept = uniqueModels(current).filter((model) => available.has(model));
return kept.length ? kept : suggested;
}
function uniqueModels(models: string[]) {
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
}
+5 -7
View File
@@ -5,7 +5,7 @@ import { Cpu } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { filterModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
type ModelPickerProps = {
config: AiConfig;
@@ -21,7 +21,7 @@ type ModelPickerProps = {
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) {
const pickerId = useId();
const [open, setOpen] = useState(false);
const options = useMemo(() => filterModelsByCapability(Array.from(new Set([...(config.channelMode === "local" ? [value] : []), ...config.models].filter((model): model is string => Boolean(model)))), capability), [capability, config.channelMode, config.models, value]);
const options = useMemo(() => Array.from(new Set([...(config.channelMode === "local" && !capability ? [value] : []), ...selectableModelsByCapability(config, capability)].filter((model): model is string => Boolean(model)))), [capability, config, value]);
const current = value || "";
useEffect(() => {
@@ -37,10 +37,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
open={open}
value={current}
onOpenChange={(nextOpen) => {
if (nextOpen && !options.length && config.channelMode === "local") {
onMissingConfig?.();
return;
}
if (nextOpen && !options.length && config.channelMode === "local") onMissingConfig?.();
if (nextOpen) window.dispatchEvent(new CustomEvent("model-picker-open", { detail: pickerId }));
setOpen(nextOpen);
}}
@@ -87,8 +84,9 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
}
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : "";
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
if (config.channelMode === "remote") return `暂无可用${label}模型`;
if (capability && config.models.length) return "请先在上方配置可选模型";
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里拉取模型列表";
}