"use client"; import { App, Button, Form, Input, Modal, Progress, Segmented, Select } from "antd"; import { Cloud, RefreshCw, Wifi } from "lucide-react"; import { useState } from "react"; import { ModelPicker } from "@/components/model-picker"; import { fetchImageModels } 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 { 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; }; 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 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 AppConfigModal() { const { message } = App.useApp(); const [loadingModels, setLoadingModels] = useState(false); 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 isConfigOpen = useConfigStore((state) => state.isConfigOpen); const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue); const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen); const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue); const publicSettings = useConfigStore((state) => state.publicSettings); const effectiveConfig = useEffectiveConfig(); const modelChannel = publicSettings?.modelChannel; 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 webdavReady = Boolean(webdav.url.trim()); const finishConfig = () => { setConfigDialogOpen(false); if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return; if (!modelConfig.imageModel.trim() || !modelConfig.videoModel.trim() || !modelConfig.textModel.trim()) return; if (!allowCustomChannel && config.channelMode !== "remote") updateConfig("channelMode", "remote"); message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存"); clearPromptContinue(); }; const refreshModels = async () => { if (effectiveMode === "remote") return; if (!config.baseUrl.trim() || !config.apiKey.trim()) { message.error("请先填写 Base URL 和 API Key"); return; } setLoadingModels(true); try { const models = await fetchImageModels(config); 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); 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 : "读取模型失败"); } finally { setLoadingModels(false); } }; 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] || ""); }; 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 (
配置与用户偏好
模型、渠道和画布默认行为
} open={isConfigOpen} width={960} centered onCancel={() => setConfigDialogOpen(false)} styles={{ body: { maxHeight: "72vh", overflowY: "auto", paddingRight: 18 } }} footer={ } >
{allowCustomChannel ? ( updateConfig("channelMode", value as AiConfig["channelMode"])} options={[ { label: "本地直连", value: "local" }, { label: "云端渠道", value: "remote" }, ]} /> ) : null} {effectiveMode === "local" ? ( <>
updateConfig("baseUrl", event.target.value)} /> updateConfig("apiKey", event.target.value)} />
模型列表
当前已保存 {config.models.length} 个模型
) : (
云端渠道
由系统后台渠道转发请求,当前可用 {modelChannel?.availableModels.length || 0} 个模型。
)} {effectiveMode === "local" ? (
本地模型可选项
从已拉取模型中选择哪些模型可进入各类下拉。
{modelGroups.map((group) => ( 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)} /> {effectiveMode === "local" ? ( updateConfig("systemPrompt", event.target.value)} /> ) : null}
WebDAV 同步
同步画布、我的素材、生成记录和本地媒体文件,不包含 AI API Key;服务不支持 CORS 时可走 Next.js 转发。
{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}
updateWebdavConfig("proxyMode", value as typeof webdav.proxyMode)} options={[ { label: "前端直连", value: "direct" }, { label: "Next.js 转发", value: "nextjs" }, ]} /> 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 ? (
{webdavDomainKeys.map((key) => { const item = webdavDomainProgress[key]; const count = item.total ? `${item.current || 0}/${item.total}` : ""; return (
{item.label} {item.stage} {count ? ` · ${count}` : ""}
); })}
) : null}
); } 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))); } function formatWebdavTime(value: string) { return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); } 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`; }