import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd"; import { CircleAlert, Cloud, 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 { 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"; type ModelGroup = { capability: ModelCapability; modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel"; modelsKey: "imageModels" | "videoModels" | "textModels" | "audioModels"; defaultLabel: string; optionsLabel: string; }; type WebdavDomainProgress = { label: string; stage: string; current?: number; total?: number; status?: "active" | "success" | "exception"; }; 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" }, ]; const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"]; const webdavDomainLabels: Record = { canvas: "画布", assets: "我的素材", "image-workbench": "生图工作台", "video-workbench": "视频创作台", }; function createWebdavDomainProgress(): Record { return webdavDomainKeys.reduce( (progress, key) => ({ ...progress, [key]: { label: webdavDomainLabels[key], stage: "等待同步" }, }), {} as Record, ); } export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) { const { message } = App.useApp(); const [activeTab, setActiveTab] = useState(initialTab); const [loadingChannelId, setLoadingChannelId] = useState(""); const [testingWebdav, setTestingWebdav] = useState(false); const [syncingWebdav, setSyncingWebdav] = useState(false); const [webdavSyncStatus, setWebdavSyncStatus] = useState(""); const [webdavDomainProgress, setWebdavDomainProgress] = useState(createWebdavDomainProgress); const config = useConfigStore((state) => state.config); const webdav = useConfigStore((state) => state.webdav); const updateConfig = useConfigStore((state) => state.updateConfig); const updateWebdavConfig = useConfigStore((state) => state.updateWebdavConfig); 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()); useEffect(() => setActiveTab(initialTab), [initialTab]); const saveConfig = (nextConfig: AiConfig) => { (Object.keys(nextConfig) as Array).forEach((key) => updateConfig(key, nextConfig[key])); }; const finishConfig = () => { const ready = config.channels.some((channel) => channel.baseUrl.trim() && channel.apiKey.trim() && channel.models.length); setConfigDialogOpen(false); if (!ready) return; message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存"); clearPromptContinue(); }; const updateChannels = (channels: ModelChannel[]) => { const nextConfig = withChannels(config, channels); saveConfig(nextConfig); }; const updateChannel = (id: string, patch: Partial) => { 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 addChannel = () => { updateChannels([...config.channels, createModelChannel({ name: `渠道 ${config.channels.length + 1}` })]); }; const deleteChannel = (id: string) => { if (config.channels.length <= 1) { message.warning("至少保留一个渠道"); return; } 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 testWebdav = async () => { if (!webdavReady) { message.error("请先填写 WebDAV 地址"); return; } setTestingWebdav(true); try { await testWebdavConnection(webdav); message.success("WebDAV 连接可用"); } catch (error) { message.error(error instanceof Error ? error.message : "WebDAV 连接测试失败"); } finally { setTestingWebdav(false); } }; const updateWebdavProgress = (event: AppSyncProgressEvent) => { setWebdavSyncStatus(event.stage); if (!event.domain) return; setWebdavDomainProgress((current) => ({ ...current, [event.domain as AppSyncDomainKey]: { label: event.label || webdavDomainLabels[event.domain as AppSyncDomainKey], stage: event.stage, current: event.current, total: event.total, status: event.status, }, })); }; const syncWebdav = async () => { if (!webdavReady) { message.error("请先填写 WebDAV 地址"); return; } setSyncingWebdav(true); setWebdavDomainProgress(createWebdavDomainProgress()); setWebdavSyncStatus("准备同步"); try { const result = await syncAppDataToWebdav(webdav, updateWebdavProgress); updateWebdavConfig("lastSyncedAt", result.syncedAt); message.success(`同步完成:${result.projects} 个画布,${result.assets} 个素材,${result.imageLogs + result.videoLogs} 条记录,本次上传 ${result.uploadedFiles} 个文件 ${formatBytes(result.uploadedBytes)}`); } catch (error) { setWebdavSyncStatus(error instanceof Error ? error.message : "WebDAV 同步失败"); message.error(error instanceof Error ? error.message : "WebDAV 同步失败"); } finally { setSyncingWebdav(false); } }; return ( <> setActiveTab(key as ConfigTabKey)} items={[ { key: "channels", label: "渠道", children: (
重要: 新增或拉取模型后,需要到“模型”Tab 选择可选项才会显示。
{config.channels.map((channel) => (
{channel.name || "未命名渠道"}
{apiFormatLabel(channel.apiFormat)} · 已保存 {channel.models.length} 个模型
updateChannel(channel.id, { name: event.target.value })} /> updateChannel(channel.id, { baseUrl: event.target.value })} /> updateChannel(channel.id, { apiKey: event.target.value })} /> updateCapabilityModels(group, models)} /> ))}
{modelGroups.map((group) => ( updateConfig(group.modelKey, model)} capability={group.capability} fullWidth /> ))}
), }, { key: "preferences", label: "生成偏好", children: (
updateConfig("canvasImageCount", event.target.value)} onBlur={(event) => updateConfig("canvasImageCount", normalizeImageCount(event.target.value))} /> updateConfig("audioFormat", value)} /> updateConfig("audioSpeed", event.target.value)} onBlur={(event) => updateConfig("audioSpeed", normalizeAudioSpeedValue(event.target.value))} />
updateConfig("audioInstructions", event.target.value)} /> updateConfig("systemPrompt", event.target.value)} />
), }, { key: "webdav", label: "WebDAV", children: (
WebDAV 同步
同步画布、我的素材、生成记录和本地媒体文件,不包含 AI API Key;浏览器会直接连接 WebDAV 服务。
{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}
updateWebdavConfig("url", event.target.value)} /> updateWebdavConfig("directory", event.target.value)} /> updateWebdavConfig("username", event.target.value)} /> updateWebdavConfig("password", event.target.value)} />
{webdavSyncStatus ? {webdavSyncStatus} : null}
{syncingWebdav || webdavSyncStatus ? : null}
), }, ]} /> {showDoneButton ? (
) : null} ); } export function AppConfigModal() { const isConfigOpen = useConfigStore((state) => state.isConfigOpen); const configTab = useConfigStore((state) => state.configTab); const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen); return (
配置与用户偏好
渠道聚合、模型选择和同步偏好
} open={isConfigOpen} width={980} centered onCancel={() => setConfigDialogOpen(false)} styles={{ body: { maxHeight: "72vh", overflowY: "auto", paddingRight: 12 } }} footer={null} > ); } 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 { ...config, channels, models, 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), }; } 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 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"; } function formatWebdavTime(value: string) { return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); } function WebdavProgressGrid({ progress }: { progress: Record }) { return (
{webdavDomainKeys.map((key) => { const item = progress[key]; const count = item.total ? `${item.current || 0}/${item.total}` : ""; return (
{item.label} {item.stage} {count ? ` · ${count}` : ""}
); })}
); } function getWebdavProgressPercent(item: WebdavDomainProgress) { if (item.status === "success") return 100; if (item.total) return Math.min(100, Math.round(((item.current || 0) / item.total) * 100)); if (item.status === "exception") return 100; if (item.stage === "等待同步") return 0; if (item.stage === "读取远端清单") return 12; if (item.stage === "读取本地数据") return 24; if (item.stage === "下载缺失媒体") return 36; if (item.stage === "写入本地合并结果") return 58; if (item.stage === "上传新增媒体") return 66; if (item.stage === "媒体已齐全" || item.stage === "媒体无需上传") return 74; if (item.stage.startsWith("上传清单")) return 90; return item.status === "active" ? 30 : 0; } function getWebdavProgressStatus(item: WebdavDomainProgress): "normal" | "active" | "success" | "exception" { if (item.status === "success" || item.status === "exception") return item.status; return item.status === "active" ? "active" : "normal"; } function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; return `${(bytes / 1024 / 1024).toFixed(1)}MB`; }