import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd"; import type { TFunction } from "i18next"; import { Cloud, Download, Pencil, Plus, RefreshCw, Trash2, Upload, Wifi } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ModelPicker } from "@/components/model-picker"; import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer"; import { ConfigPromptSources } from "@/components/layout/config-prompt-sources"; import type { AppLocale } from "@/i18n"; import { exportAppConfig, importAppConfig } from "@/services/config-file"; 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, 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"; labelKey: string; }; type WebdavDomainProgress = { stage: string; current?: number; total?: number; status?: "active" | "success" | "exception"; }; const modelGroups: ModelGroup[] = [ { capability: "image", modelKey: "imageModel", labelKey: "config.preferences.defaultImageModel" }, { capability: "video", modelKey: "videoModel", labelKey: "config.preferences.defaultVideoModel" }, { capability: "text", modelKey: "textModel", labelKey: "config.preferences.defaultTextModel" }, { capability: "audio", modelKey: "audioModel", labelKey: "config.preferences.defaultAudioModel" }, ]; const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"]; function createWebdavDomainProgress(): Record { return webdavDomainKeys.reduce( (progress, key) => ({ ...progress, [key]: { stage: "等待同步" }, }), {} as Record, ); } export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) { const { message } = App.useApp(); const { i18n, t } = useTranslation(); const configInputRef = useRef(null); const [activeTab, setActiveTab] = useState(initialTab); const [editingChannelId, setEditingChannelId] = 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 webdavReady = Boolean(webdav.url.trim()); const editingChannel = config.channels.find((channel) => channel.id === editingChannelId) || null; const locale = i18n.resolvedLanguage as AppLocale; 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(t(shouldPromptContinue ? "config.savedContinue" : "config.saved")); clearPromptContinue(); }; const loadConfigFile = async (file: File) => { try { await importAppConfig(file); message.success(t("config.imported")); } catch (error) { message.error(error instanceof Error ? error.message : t("config.importFailed")); } finally { if (configInputRef.current) configInputRef.current.value = ""; } }; const updateChannels = (channels: ModelChannel[]) => saveConfig(withChannels(config, channels)); const addChannel = () => { const channel = createModelChannel({ name: t("config.channels.numberedName", { count: config.channels.length + 1 }) }); updateChannels([...config.channels, channel]); setEditingChannelId(channel.id); }; const deleteChannel = (id: string) => { if (config.channels.length <= 1) { message.warning(t("config.channels.keepOne")); return; } updateChannels(config.channels.filter((channel) => channel.id !== id)); }; const saveChannel = (channel: ModelChannel) => { updateChannels(config.channels.map((item) => (item.id === channel.id ? channel : item))); }; const testWebdav = async () => { if (!webdavReady) { message.error(t("config.webdav.missingUrl")); return; } setTestingWebdav(true); try { await testWebdavConnection(webdav); message.success(t("config.webdav.available")); } catch (error) { message.error(error instanceof Error ? error.message : t("config.webdav.testFailed")); } finally { setTestingWebdav(false); } }; const updateWebdavProgress = (event: AppSyncProgressEvent) => { setWebdavSyncStatus(event.stage); if (!event.domain) return; setWebdavDomainProgress((current) => ({ ...current, [event.domain as AppSyncDomainKey]: { stage: event.stage, current: event.current, total: event.total, status: event.status, }, })); }; const syncWebdav = async () => { if (!webdavReady) { message.error(t("config.webdav.missingUrl")); return; } setSyncingWebdav(true); setWebdavDomainProgress(createWebdavDomainProgress()); setWebdavSyncStatus(t("config.webdav.preparing")); try { const result = await syncAppDataToWebdav(webdav, updateWebdavProgress); updateWebdavConfig("lastSyncedAt", result.syncedAt); message.success(t("config.webdav.completed", { projects: result.projects, assets: result.assets, records: result.imageLogs + result.videoLogs, files: result.uploadedFiles, bytes: formatBytes(result.uploadedBytes) })); } catch (error) { setWebdavSyncStatus(error instanceof Error ? error.message : t("config.webdav.failed")); message.error(error instanceof Error ? error.message : t("config.webdav.failed")); } finally { setSyncingWebdav(false); } }; return ( <>
{t("config.fileSecurity")}
event.target.files?.[0] && void loadConfigFile(event.target.files[0])} />
setActiveTab(key as ConfigTabKey)} items={[ { key: "channels", label: t("config.tabs.channels"), children: (
{t("config.channels.description")}
{config.channels.map((channel) => (
{channel.name || t("config.channels.unnamed")}
{apiFormatLabel(channel.apiFormat, t)} · {t("config.channels.modelCount", { count: channel.models.length })} · {channel.baseUrl || t("config.channels.missingUrl")}
))}
), }, { key: "preferences", label: t("config.tabs.preferences"), children: (
{t("config.preferences.defaultModels")}
{modelGroups.map((group) => ( updateConfig(group.modelKey, model)} capability={group.capability} fullWidth /> ))}
{t("config.preferences.generation")}
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: "prompt-sources", label: t("config.tabs.promptSources"), children: , }, { key: "webdav", label: "WebDAV", children: (
{t("config.webdav.title")}
{t("config.webdav.description")}
{webdav.lastSyncedAt ? t("config.webdav.lastSynced", { time: formatWebdavTime(webdav.lastSyncedAt, locale) }) : t("config.webdav.neverSynced")}
updateWebdavConfig("url", event.target.value)} /> updateWebdavConfig("directory", event.target.value)} /> updateWebdavConfig("username", event.target.value)} /> updateWebdavConfig("password", event.target.value)} />
{webdavSyncStatus ? {syncStageLabel(webdavSyncStatus, t)} : null}
{syncingWebdav || webdavSyncStatus ? : null}
), }, ]} /> {showDoneButton ? (
) : null} setEditingChannelId("")} /> ); } export function AppConfigModal() { const { t } = useTranslation(); const isConfigOpen = useConfigStore((state) => state.isConfigOpen); const configTab = useConfigStore((state) => state.configTab); const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen); return (
{t("config.title")}
{t("config.modalDescription")}
} 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 next: AiConfig = { ...config, channels, models: modelOptionsFromChannels(channels), baseUrl: channels[0]?.baseUrl || config.baseUrl, apiKey: channels[0]?.apiKey || config.apiKey, apiFormat: channels[0]?.apiFormat || config.apiFormat, }; 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 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 apiFormatLabel(apiFormat: ApiCallFormat, t: TFunction) { if (apiFormat === "gemini") return "Gemini"; if (apiFormat === "ark") return t("config.protocols.ark"); return "OpenAI"; } function formatWebdavTime(value: string, locale: AppLocale) { return new Date(value).toLocaleString(locale, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); } function WebdavProgressGrid({ progress, t }: { progress: Record; t: TFunction }) { return (
{webdavDomainKeys.map((key) => { const item = progress[key]; const count = item.total ? `${item.current || 0}/${item.total}` : ""; return (
{t(`config.webdav.domains.${domainTranslationKey(key)}`)} {syncStageLabel(item.stage, t)} {count ? ` · ${count}` : ""}
); })}
); } function domainTranslationKey(domain: AppSyncDomainKey) { if (domain === "image-workbench") return "imageWorkbench"; if (domain === "video-workbench") return "videoWorkbench"; return domain; } function syncStageLabel(stage: string, t: TFunction) { if (stage === "等待本地数据加载") return t("config.webdav.stages.localWaiting"); if (stage === "同步完成") return t("config.webdav.stages.syncComplete"); if (stage === "等待同步") return t("config.webdav.stages.waiting"); if (stage === "读取远端清单") return t("config.webdav.stages.remoteManifest"); if (stage === "读取本地数据") return t("config.webdav.stages.localData"); if (stage === "下载缺失媒体") return t("config.webdav.stages.downloadMedia"); if (stage === "写入本地合并结果") return t("config.webdav.stages.writeMerge"); if (stage === "上传新增媒体") return t("config.webdav.stages.uploadMedia"); if (stage === "媒体已齐全") return t("config.webdav.stages.mediaReady"); if (stage === "媒体无需上传") return t("config.webdav.stages.mediaSkipped"); if (stage === "检查缺失媒体") return t("config.webdav.stages.checkMissingMedia"); if (stage === "下载媒体") return t("config.webdav.stages.downloadMediaFile"); if (stage === "检查本地媒体") return t("config.webdav.stages.checkLocalMedia"); if (stage.startsWith("上传媒体 ")) return t("config.webdav.stages.uploadMediaFile", { size: stage.slice(5) }); if (stage === "完成") return t("config.webdav.stages.complete"); if (stage.startsWith("上传清单 ")) return t("config.webdav.stages.uploadManifest", { size: stage.slice(5) }); return stage; } 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`; }