diff --git a/web/src/components/layout/app-config-modal.tsx b/web/src/components/layout/app-config-modal.tsx new file mode 100644 index 0000000..d2a9ab6 --- /dev/null +++ b/web/src/components/layout/app-config-modal.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { App, Button, Form, Input, Modal, Segmented } from "antd"; +import { useState } from "react"; + +import { ModelPicker } from "@/components/model-picker"; +import { fetchImageModels } from "@/services/api/image"; +import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; + +export function AppConfigModal() { + const { message } = App.useApp(); + const [loadingModels, setLoadingModels] = useState(false); + const config = useConfigStore((state) => state.config); + const updateConfig = useConfigStore((state) => state.updateConfig); + 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 finishConfig = () => { + setConfigDialogOpen(false); + if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return; + if (!modelConfig.imageModel.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); + updateConfig("models", models); + if (models.length && !models.includes(config.imageModel)) updateConfig("imageModel", models[0]); + if (models.length && !models.includes(config.textModel)) updateConfig("textModel", models[0]); + message.success("模型列表已更新"); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取模型失败"); + } finally { + setLoadingModels(false); + } + }; + + return ( +
配置
模型和密钥
} + open={isConfigOpen} + width={760} + centered + onCancel={() => setConfigDialogOpen(false)} + footer={} + > +
+
+ {allowCustomChannel ? ( + + updateConfig("channelMode", value as AiConfig["channelMode"])} + options={[{ label: "云端渠道", value: "remote" }, { label: "本地直连", value: "local" }]} + /> + + ) : null} + {effectiveMode === "local" ? ( + <> +
+ + updateConfig("baseUrl", event.target.value)} /> + + + updateConfig("apiKey", event.target.value)} /> + +
+
+
+
模型列表
+
当前已保存 {config.models.length} 个模型
+
+ +
+ + ) : ( +
+
云端渠道
+
由系统后台渠道转发请求,当前可用 {modelChannel?.availableModels.length || 0} 个模型。
+
+ )} +
+ + updateConfig("imageModel", model)} fullWidth /> + + + updateConfig("textModel", model)} fullWidth /> + +
+ {effectiveMode === "local" ? ( + + updateConfig("systemPrompt", event.target.value)} /> + + ) : null} +
+
+
+ ); +} diff --git a/web/src/components/layout/app-top-nav.tsx b/web/src/components/layout/app-top-nav.tsx index 3554cd7..3a8ff50 100644 --- a/web/src/components/layout/app-top-nav.tsx +++ b/web/src/components/layout/app-top-nav.tsx @@ -2,79 +2,32 @@ import { Menu, Settings2 } from "lucide-react"; import Link from "next/link"; -import { App, Button, Drawer, Form, Input, Modal, Segmented } from "antd"; import { usePathname } from "next/navigation"; -import { ModelPicker } from "@/components/model-picker"; import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler"; import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools"; +import { AppConfigModal } from "@/components/layout/app-config-modal"; import { GitHubLink } from "@/components/layout/github-link"; +import { MobileNavDrawer } from "@/components/layout/mobile-nav-drawer"; import { UserStatusActions } from "@/components/layout/user-status-actions"; import { VersionReleaseModal } from "@/components/layout/version-release-modal"; -import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; -import { fetchImageModels } from "@/services/api/image"; +import { useConfigStore } from "@/stores/use-config-store"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import { cn } from "@/lib/utils"; import { useState } from "react"; export function AppTopNav() { - const { message } = App.useApp(); const pathname = usePathname(); - const [loadingModels, setLoadingModels] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false); - const config = useConfigStore((state) => state.config); - const updateConfig = useConfigStore((state) => state.updateConfig); - const isConfigOpen = useConfigStore((state) => state.isConfigOpen); - const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); - const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen); - const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue); const theme = useThemeStore((state) => state.theme); const setTheme = useThemeStore((state) => state.setTheme); const user = useUserStore((state) => state.user); const isReady = useUserStore((state) => state.isReady); - const publicSettings = useConfigStore((state) => state.publicSettings); - const effectiveConfig = useEffectiveConfig(); const hideHeader = /^\/canvas\/[^/]+/.test(pathname); const slug = pathname.split("/").filter(Boolean)[0]; const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined; - const modelChannel = publicSettings?.modelChannel; - const allowCustomChannel = modelChannel?.allowCustomChannel === true; - const effectiveMode = allowCustomChannel ? config.channelMode : "remote"; - const modelConfig = effectiveMode === "remote" ? effectiveConfig : config; - - const finishConfig = () => { - setConfigDialogOpen(false); - if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return; - if (!modelConfig.imageModel.trim() || !modelConfig.textModel.trim()) return; - if (!allowCustomChannel && config.channelMode !== "remote") updateConfig("channelMode", "remote"); - if (shouldPromptContinue) { - message.success("配置已保存,请继续刚才的请求"); - } else { - message.success("配置已保存"); - } - 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); - updateConfig("models", models); - if (models.length && !models.includes(config.imageModel)) updateConfig("imageModel", models[0]); - if (models.length && !models.includes(config.textModel)) updateConfig("textModel", models[0]); - message.success("模型列表已更新"); - } catch (error) { - message.error(error instanceof Error ? error.message : "读取模型失败"); - } finally { - setLoadingModels(false); - } - }; return ( <> @@ -162,98 +115,8 @@ export function AppTopNav() { ) : null} - setMobileNavOpen(false)} - className="md:hidden" - > -
- {navigationTools.map((tool) => { - const Icon = tool.icon; - const active = tool.slug === activeToolSlug; - return ( - setMobileNavOpen(false)} - className={cn( - "flex items-center gap-3 rounded-lg px-3 py-3 text-base transition", - active - ? "bg-stone-100 font-medium text-stone-950 dark:bg-stone-800 dark:text-stone-100" - : "text-stone-600 hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100", - )} - > - - {tool.label} - - ); - })} -
-
- -
配置
模型和密钥
} - open={isConfigOpen} - width={760} - centered - onCancel={() => setConfigDialogOpen(false)} - footer={} - > -
-
- {allowCustomChannel ? ( - - updateConfig("channelMode", value as AiConfig["channelMode"])} - options={[{ label: "云端渠道", value: "remote" }, { label: "本地直连", value: "local" }]} - /> - - ) : null} - {effectiveMode === "local" ? ( - <> -
- - updateConfig("baseUrl", event.target.value)} /> - - - updateConfig("apiKey", event.target.value)} /> - -
-
-
-
模型列表
-
当前已保存 {config.models.length} 个模型
-
- -
- - ) : ( -
-
云端渠道
-
由系统后台渠道转发请求,当前可用 {modelChannel?.availableModels.length || 0} 个模型。
-
- )} -
- - updateConfig("imageModel", model)} fullWidth /> - - - updateConfig("textModel", model)} fullWidth /> - -
- {effectiveMode === "local" ? ( - - updateConfig("systemPrompt", event.target.value)} /> - - ) : null} -
-
-
+ setMobileNavOpen(false)} /> + ); diff --git a/web/src/components/layout/mobile-nav-drawer.tsx b/web/src/components/layout/mobile-nav-drawer.tsx new file mode 100644 index 0000000..ec909b4 --- /dev/null +++ b/web/src/components/layout/mobile-nav-drawer.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Drawer } from "antd"; +import Link from "next/link"; + +import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools"; +import { cn } from "@/lib/utils"; + +type MobileNavDrawerProps = { + open: boolean; + activeToolSlug?: NavigationToolSlug; + onClose: () => void; +}; + +export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDrawerProps) { + return ( + +
+ {navigationTools.map((tool) => { + const Icon = tool.icon; + const active = tool.slug === activeToolSlug; + return ( + + + {tool.label} + + ); + })} +
+
+ ); +} diff --git a/web/src/lib/app-theme.ts b/web/src/lib/app-theme.ts index 85bd16f..900a007 100644 --- a/web/src/lib/app-theme.ts +++ b/web/src/lib/app-theme.ts @@ -9,6 +9,9 @@ const neutral = { primaryText: "#ffffff", menuBg: "#f5f5f5", menuText: "#171717", + selectActiveBg: "#f5f5f5", + selectSelectedBg: "#f0f0f0", + selectText: "#171717", }, dark: { primary: "#fafafa", @@ -16,6 +19,9 @@ const neutral = { primaryText: "#171717", menuBg: "#262626", menuText: "#fafafa", + selectActiveBg: "#262626", + selectSelectedBg: "#333333", + selectText: "#fafafa", }, }; @@ -54,6 +60,11 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig { darkItemSelectedBg: neutral.dark.menuBg, darkItemSelectedColor: neutral.dark.menuText, }, + Select: { + optionActiveBg: color.selectActiveBg, + optionSelectedBg: color.selectSelectedBg, + optionSelectedColor: color.selectText, + }, }, }; }