feat(model): enhance model configuration and script handling for audio and video capabilities

This commit is contained in:
HouYunFei
2026-07-15 10:13:32 +08:00
parent a4dcc679c9
commit c57f7d61a7
13 changed files with 690 additions and 266 deletions
+54 -182
View File
@@ -1,20 +1,18 @@
import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd";
import { CircleAlert, Cloud, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
import { Cloud, Pencil, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
import { useEffect, useState } from "react";
import { ModelPicker } from "@/components/model-picker";
import { fetchChannelModels } from "@/services/api/image";
import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer";
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
import { createModelChannel, defaultBaseUrlForApiFormat, filterModelsByCapability, modelOptionLabel, modelOptionsFromChannels, normalizeModelOptionValue, useConfigStore, type AiConfig, type ApiCallFormat, type ConfigTabKey, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import { createModelChannel, modelOptionsFromChannels, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore, type AiConfig, type ApiCallFormat, type ConfigTabKey, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
type ModelGroup = {
capability: ModelCapability;
modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel";
modelsKey: "imageModels" | "videoModels" | "textModels" | "audioModels";
defaultLabel: string;
optionsLabel: string;
};
type WebdavDomainProgress = {
@@ -26,15 +24,10 @@ type WebdavDomainProgress = {
};
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: "音频模型可选项" },
];
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
{ label: "OpenAI", value: "openai" },
{ label: "Gemini", value: "gemini" },
{ capability: "image", modelKey: "imageModel", defaultLabel: "默认生图模型" },
{ capability: "video", modelKey: "videoModel", defaultLabel: "默认视频模型" },
{ capability: "text", modelKey: "textModel", defaultLabel: "默认文本模型" },
{ capability: "audio", modelKey: "audioModel", defaultLabel: "默认音频模型" },
];
const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"];
@@ -58,7 +51,7 @@ function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProg
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
const { message } = App.useApp();
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
const [loadingChannelId, setLoadingChannelId] = useState("");
const [editingChannelId, setEditingChannelId] = useState("");
const [testingWebdav, setTestingWebdav] = useState(false);
const [syncingWebdav, setSyncingWebdav] = useState(false);
const [webdavSyncStatus, setWebdavSyncStatus] = useState("");
@@ -70,8 +63,8 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
const modelOptions = config.models.map((model) => ({ label: modelOptionLabel(config, model), value: model }));
const webdavReady = Boolean(webdav.url.trim());
const editingChannel = config.channels.find((channel) => channel.id === editingChannelId) || null;
useEffect(() => setActiveTab(initialTab), [initialTab]);
const saveConfig = (nextConfig: AiConfig) => {
@@ -86,22 +79,12 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
clearPromptContinue();
};
const updateChannels = (channels: ModelChannel[]) => {
const nextConfig = withChannels(config, channels);
saveConfig(nextConfig);
};
const updateChannel = (id: string, patch: Partial<ModelChannel>) => {
updateChannels(config.channels.map((channel) => (channel.id === id ? { ...channel, ...patch, models: patch.models ? uniqueModels(patch.models) : channel.models } : channel)));
};
const updateChannelApiFormat = (channel: ModelChannel, apiFormat: ApiCallFormat) => {
const baseUrl = !channel.baseUrl.trim() || channel.baseUrl.trim() === defaultBaseUrlForApiFormat(channel.apiFormat) ? defaultBaseUrlForApiFormat(apiFormat) : channel.baseUrl;
updateChannel(channel.id, { apiFormat, baseUrl });
};
const updateChannels = (channels: ModelChannel[]) => saveConfig(withChannels(config, channels));
const addChannel = () => {
updateChannels([...config.channels, createModelChannel({ name: `渠道 ${config.channels.length + 1}` })]);
const channel = createModelChannel({ name: `渠道 ${config.channels.length + 1}` });
updateChannels([...config.channels, channel]);
setEditingChannelId(channel.id);
};
const deleteChannel = (id: string) => {
@@ -112,46 +95,8 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
updateChannels(config.channels.filter((channel) => channel.id !== id));
};
const refreshChannelModels = async (channel: ModelChannel) => {
if (!channel.baseUrl.trim() || !channel.apiKey.trim()) {
message.error("请先填写该渠道的 Base URL 和 API Key");
return;
}
setLoadingChannelId(channel.id);
try {
const models = await fetchChannelModels(channel);
updateChannels(config.channels.map((item) => (item.id === channel.id ? { ...item, models } : item)));
message.success(`${channel.name} 模型列表已更新`);
} catch (error) {
message.error(error instanceof Error ? error.message : "读取模型失败");
} finally {
setLoadingChannelId("");
}
};
const refreshAllModels = async () => {
const runnable = config.channels.filter((channel) => channel.baseUrl.trim() && channel.apiKey.trim());
if (!runnable.length) {
message.error("请先填写至少一个渠道的 Base URL 和 API Key");
return;
}
setLoadingChannelId("all");
try {
const entries = await Promise.all(runnable.map(async (channel) => [channel.id, await fetchChannelModels(channel)] as const));
const modelMap = new Map(entries);
updateChannels(config.channels.map((channel) => (modelMap.has(channel.id) ? { ...channel, models: modelMap.get(channel.id) || [] } : channel)));
message.success("模型列表已更新");
} catch (error) {
message.error(error instanceof Error ? error.message : "读取模型失败");
} finally {
setLoadingChannelId("");
}
};
const updateCapabilityModels = (group: ModelGroup, models: string[]) => {
const next = uniqueModels(models.map((model) => normalizeModelOptionValue(model, config.channels)).filter(Boolean));
updateConfig(group.modelsKey, next);
if (!next.includes(config[group.modelKey])) updateConfig(group.modelKey, next[0] || "");
const saveChannel = (channel: ModelChannel) => {
updateChannels(config.channels.map((item) => (item.id === channel.id ? channel : item)));
};
const testWebdav = async () => {
@@ -215,107 +160,48 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
key: "channels",
label: "渠道",
children: (
<Form layout="vertical" requiredMark={false}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="min-w-0 flex-1">
<div className="flex w-fit max-w-full flex-wrap items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2.5 py-1.5 text-xs text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/30 dark:text-amber-100">
<CircleAlert className="size-3.5 shrink-0" />
<span className="font-semibold"></span>
<span>Tab </span>
<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold text-amber-900 dark:text-amber-100" onClick={() => setActiveTab("models")}>
</Button>
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button icon={<RefreshCw className="size-4" />} loading={Boolean(loadingChannelId)} onClick={() => void refreshAllModels()}>
</Button>
<Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
</Button>
</div>
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="text-xs text-stone-500">///</div>
<Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
</Button>
</div>
<div className="space-y-3">
<div className="space-y-2">
{config.channels.map((channel) => (
<section key={channel.id} className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
<div className="mt-1 text-xs text-stone-500">
{apiFormatLabel(channel.apiFormat)} · {channel.models.length}
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button size="small" loading={loadingChannelId === channel.id} onClick={() => void refreshChannelModels(channel)}>
</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
<div key={channel.id} className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
<div className="mt-1 truncate text-xs text-stone-500">
{apiFormatLabel(channel.apiFormat)} · {channel.models.length} · {channel.baseUrl || "未填写接口地址"}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="渠道名称" className="mb-0">
<Input value={channel.name} onChange={(event) => updateChannel(channel.id, { name: event.target.value })} />
</Form.Item>
<Form.Item label="调用格式" className="mb-0">
<Select value={channel.apiFormat} options={apiFormatOptions} onChange={(value: ApiCallFormat) => updateChannelApiFormat(channel, value)} />
</Form.Item>
<Form.Item label="Base URL" className="mb-0">
<Input value={channel.baseUrl} onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })} />
</Form.Item>
<Form.Item label="API Key" className="mb-0">
<Input.Password value={channel.apiKey} onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })} />
</Form.Item>
<Form.Item label="模型列表" className="mb-0 md:col-span-2">
<Select mode="tags" showSearch allowClear maxTagCount="responsive" placeholder="输入模型名,或点击拉取模型" value={channel.models} onChange={(models) => updateChannel(channel.id, { models })} />
</Form.Item>
<div className="flex shrink-0 gap-2">
<Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingChannelId(channel.id)}>
</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
</div>
</section>
</div>
))}
</div>
</Form>
</div>
),
},
{
key: "models",
label: "模型",
key: "preferences",
label: "偏好设置",
children: (
<Form layout="vertical" requiredMark={false}>
<div className="mb-4 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="text-sm font-semibold"></div>
<div className="mt-1 text-xs leading-5 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="tags"
showSearch
allowClear
maxTagCount="responsive"
placeholder={config.models.length ? `请选择或输入${group.optionsLabel}` : "先到渠道里填写或拉取模型"}
value={config[group.modelsKey]}
options={modelOptions}
onChange={(models) => updateCapabilityModels(group, models)}
/>
</Form.Item>
))}
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="mb-2 text-sm font-semibold"></div>
<div className="mb-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{modelGroups.map((group) => (
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-0">
<ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
</Form.Item>
))}
</div>
</Form>
),
},
{
key: "preferences",
label: "生成偏好",
children: (
<Form layout="vertical" requiredMark={false}>
<div className="mb-2 text-sm font-semibold"></div>
<div className="grid gap-4 md:grid-cols-4">
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
<Input
@@ -407,6 +293,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
</Button>
</div>
) : null}
<ChannelEditorDrawer open={Boolean(editingChannel)} channel={editingChannel} onSave={saveChannel} onClose={() => setEditingChannelId("")} />
</>
);
}
@@ -420,7 +307,7 @@ export function AppConfigModal() {
title={
<div>
<div className="text-lg font-semibold"></div>
<div className="mt-1 text-xs font-normal text-stone-500"></div>
<div className="mt-1 text-xs font-normal text-stone-500"></div>
</div>
}
open={isConfigOpen}
@@ -436,48 +323,33 @@ export function AppConfigModal() {
}
function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
const models = modelOptionsFromChannels(channels);
const imageModels = keepOrSuggest(config.imageModels, filterModelsByCapability(models, "image"), models);
const videoModels = keepOrSuggest(config.videoModels, filterModelsByCapability(models, "video"), models);
const textModels = keepOrSuggest(config.textModels, filterModelsByCapability(models, "text"), models);
const audioModels = keepOrSuggest(config.audioModels, filterModelsByCapability(models, "audio"), models);
return {
const next: AiConfig = {
...config,
channels,
models,
models: modelOptionsFromChannels(channels),
baseUrl: channels[0]?.baseUrl || config.baseUrl,
apiKey: channels[0]?.apiKey || config.apiKey,
apiFormat: channels[0]?.apiFormat || config.apiFormat,
imageModels,
videoModels,
textModels,
audioModels,
imageModel: normalizeDefaultModel(config.imageModel, imageModels),
videoModel: normalizeDefaultModel(config.videoModel, videoModels),
textModel: normalizeDefaultModel(config.textModel, textModels),
audioModel: normalizeDefaultModel(config.audioModel, audioModels),
};
return {
...next,
imageModel: pickDefaultModel(next, "image", config.imageModel),
videoModel: pickDefaultModel(next, "video", config.videoModel),
textModel: pickDefaultModel(next, "text", config.textModel),
audioModel: pickDefaultModel(next, "audio", config.audioModel),
};
}
function keepOrSuggest(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 normalizeDefaultModel(value: string, options: string[]) {
if (options.includes(value)) return value;
return options[0] || value;
function pickDefaultModel(config: AiConfig, capability: ModelCapability, current: string) {
const options = selectableModelsByCapability(config, capability);
const normalized = normalizeModelOptionValue(current, config.channels);
return options.includes(normalized) ? normalized : options[0] || "";
}
function normalizeImageCount(value: string) {
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3))));
}
function uniqueModels(models: string[]) {
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
}
function apiFormatLabel(apiFormat: ApiCallFormat) {
return apiFormat === "gemini" ? "Gemini" : "OpenAI";
}