import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd"; import { Cloud, Pencil, Plus, RefreshCw, Trash2, Wifi } from "lucide-react"; import { useEffect, useState } from "react"; import { ModelPicker } from "@/components/model-picker"; import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer"; import { ConfigPromptSources } from "@/components/layout/config-prompt-sources"; 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"; defaultLabel: string; }; type WebdavDomainProgress = { label: string; stage: string; current?: number; total?: number; status?: "active" | "success" | "exception"; }; const modelGroups: ModelGroup[] = [ { 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"]; 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 [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; 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[]) => saveConfig(withChannels(config, channels)); const addChannel = () => { const channel = createModelChannel({ name: `渠道 ${config.channels.length + 1}` }); updateChannels([...config.channels, channel]); setEditingChannelId(channel.id); }; const deleteChannel = (id: string) => { if (config.channels.length <= 1) { message.warning("至少保留一个渠道"); 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("请先填写 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: (
每个渠道选择一个协议并拉取模型,为每个模型指定能力(生图/视频/文本/音频),并可自定义调用脚本。
{config.channels.map((channel) => (
{channel.name || "未命名渠道"}
{apiFormatLabel(channel.apiFormat)} · {channel.models.length} 个模型 · {channel.baseUrl || "未填写接口地址"}
))}
), }, { key: "preferences", label: "偏好设置", children: (
默认模型
{modelGroups.map((group) => ( updateConfig(group.modelKey, model)} capability={group.capability} fullWidth /> ))}
生成偏好
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: "提示词来源", children: , }, { 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} setEditingChannelId("")} /> ); } 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 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) { 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`; }