diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c3600..d562104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## Unreleased -+ [新增] 管理后台新增系统设置页面,支持可视化编辑和手动编辑 JSON。 ++ [新增] 管理后台新增系统设置页面,按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON。 ++ [调整] 模型渠道配置改为列表和抽屉编辑,新增协议、权重、远程获取模型列表、模型清单测试和批量测试能力。 + [新增] 后端新增 `settings` 表和系统设置接口,支持 public/private 两行 JSON 配置。 + [修复] Docker 构建和运行阶段补充拷贝 `CHANGELOG.md`,避免版本信息读取失败。 diff --git a/docs/backend-database.md b/docs/backend-database.md index a307e70..e8125f1 100644 --- a/docs/backend-database.md +++ b/docs/backend-database.md @@ -116,7 +116,7 @@ | `defaultImageModel` | string | 默认图片模型 | | `defaultTextModel` | string | 默认文本模型 | | `systemPrompt` | string | 系统提示词 | -| `allowCustomModel` | bool | 是否允许用户自定义模型 | +| `allowCustomChannel` | bool | 是否允许用户自定义渠道,允许时前端可提供走后端渠道和自定义 baseUrl 直连两种模式 | `private.value` 当前字段: @@ -128,14 +128,17 @@ | 字段 | 类型 | 说明 | |----------|----------|----------| -| `key` | string | 渠道标识 | +| `protocol` | string | 协议,当前支持 `openai` | | `name` | string | 渠道名称 | | `baseUrl` | string | 渠道接口地址 | | `apiKey` | string | 渠道密钥 | | `models` | string[] | 渠道可用模型列表 | +| `weight` | number | 渠道权重,同一模型命中多个渠道时按权重随机 | | `enabled` | bool | 是否启用 | | `remark` | string | 备注 | +后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。 + ### dicts 字典表。一个字典一行,具体字典项数据放在 `items`。 diff --git a/docs/pending-test.md b/docs/pending-test.md index 3b6948d..a0d40ee 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -4,4 +4,5 @@ - 设置弹窗中填写系统提示词后,生图、编辑图和画布助手文本请求应携带该提示词。 - Docker 镜像构建和运行时,前端应能正常读取 `CHANGELOG.md` 并完成版本信息注入。 - 后端应自动维护 `settings` 表,并支持公开读取 public 配置、管理员读取和保存 public/private 两行 JSON 配置。 -- 管理后台系统设置页面应支持可视化编辑和手动编辑 JSON,并能保存 public/private 配置。 +- 管理后台系统设置页面应按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON,且可保存 public/private 配置。 +- 管理后台私有配置中的模型渠道应以列表展示、通过抽屉新增编辑,并支持填写权重、远程获取模型列表、模型清单测试和批量测试。 diff --git a/model/setting.go b/model/setting.go index b799651..0466477 100644 --- a/model/setting.go +++ b/model/setting.go @@ -11,23 +11,24 @@ const ( // ModelChannel 模型渠道配置。 type ModelChannel struct { - Key string `json:"key"` - Name string `json:"name"` - BaseURL string `json:"baseUrl"` - APIKey string `json:"apiKey"` - Models []string `json:"models"` - Enabled bool `json:"enabled"` - Remark string `json:"remark"` + Protocol string `json:"protocol"` + Name string `json:"name"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"apiKey"` + Models []string `json:"models"` + Weight int `json:"weight"` + Enabled bool `json:"enabled"` + Remark string `json:"remark"` } // PublicSetting 公开配置。 type PublicSetting struct { - AvailableModels []string `json:"availableModels"` - DefaultModel string `json:"defaultModel"` - DefaultImageModel string `json:"defaultImageModel"` - DefaultTextModel string `json:"defaultTextModel"` - SystemPrompt string `json:"systemPrompt"` - AllowCustomModel bool `json:"allowCustomModel"` + AvailableModels []string `json:"availableModels"` + DefaultModel string `json:"defaultModel"` + DefaultImageModel string `json:"defaultImageModel"` + DefaultTextModel string `json:"defaultTextModel"` + SystemPrompt string `json:"systemPrompt"` + AllowCustomChannel bool `json:"allowCustomChannel"` } // PrivateSetting 私有配置。 @@ -48,3 +49,26 @@ type Settings struct { Public PublicSetting `json:"public"` Private PrivateSetting `json:"private"` } + +func (setting *PublicSetting) UnmarshalJSON(data []byte) error { + type alias struct { + AvailableModels []string `json:"availableModels"` + DefaultModel string `json:"defaultModel"` + DefaultImageModel string `json:"defaultImageModel"` + DefaultTextModel string `json:"defaultTextModel"` + SystemPrompt string `json:"systemPrompt"` + AllowCustomChannel bool `json:"allowCustomChannel"` + AllowCustomModel bool `json:"allowCustomModel"` + } + var value alias + if err := json.Unmarshal(data, &value); err != nil { + return err + } + setting.AvailableModels = value.AvailableModels + setting.DefaultModel = value.DefaultModel + setting.DefaultImageModel = value.DefaultImageModel + setting.DefaultTextModel = value.DefaultTextModel + setting.SystemPrompt = value.SystemPrompt + setting.AllowCustomChannel = value.AllowCustomChannel || value.AllowCustomModel + return nil +} diff --git a/service/settings.go b/service/settings.go index fb32ecc..10136aa 100644 --- a/service/settings.go +++ b/service/settings.go @@ -1,6 +1,10 @@ package service import ( + "errors" + "math/rand" + "strings" + "github.com/basketikun/infinite-canvas/model" "github.com/basketikun/infinite-canvas/repository" ) @@ -37,9 +41,54 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting setting.Channels = []model.ModelChannel{} } for i := range setting.Channels { + if setting.Channels[i].Protocol == "" { + setting.Channels[i].Protocol = "openai" + } if setting.Channels[i].Models == nil { setting.Channels[i].Models = []string{} } + if setting.Channels[i].Weight <= 0 { + setting.Channels[i].Weight = 1 + } } return setting } + +func SelectModelChannel(modelName string) (model.ModelChannel, error) { + settings, err := repository.GetSettings() + if err != nil { + return model.ModelChannel{}, err + } + channels := modelChannelsForModel(normalizePrivateSetting(settings.Private).Channels, modelName) + if len(channels) == 0 { + return model.ModelChannel{}, errors.New("没有可用模型渠道") + } + total := 0 + for _, channel := range channels { + total += channel.Weight + } + hit := rand.Intn(total) + for _, channel := range channels { + hit -= channel.Weight + if hit < 0 { + return channel, nil + } + } + return channels[0], nil +} + +func modelChannelsForModel(channels []model.ModelChannel, modelName string) []model.ModelChannel { + result := []model.ModelChannel{} + for _, channel := range channels { + if !channel.Enabled || channel.BaseURL == "" || channel.APIKey == "" { + continue + } + for _, item := range channel.Models { + if strings.TrimSpace(item) == modelName { + result = append(result, channel) + break + } + } + } + return result +} diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx index 38035d7..025c40e 100644 --- a/web/src/app/(admin)/admin/settings/page.tsx +++ b/web/src/app/(admin)/admin/settings/page.tsx @@ -1,15 +1,15 @@ "use client"; -import { CheckCircleOutlined, CodeOutlined, DeleteOutlined, FormatPainterOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons"; +import { CheckCircleOutlined, DeleteOutlined, FormatPainterOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons"; import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { json } from "@codemirror/lang-json"; import { tags } from "@lezer/highlight"; -import { App, Button, Card, Col, Flex, Form, Input, Row, Segmented, Select, Space, Switch, Tag, Typography } from "antd"; +import { App, Button, Card, Col, Drawer, Flex, Form, Input, InputNumber, Modal, Row, Segmented, Select, Space, Switch, Table, Tabs, Tag, Typography } from "antd"; import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; import { EditorView } from "@uiw/react-codemirror"; -import { fetchAdminSettings, saveAdminSettings, type AdminSettings } from "@/services/api/admin"; +import { fetchAdminSettings, fetchChannelModels, saveAdminSettings, testChannelModel, type AdminModelChannel, type AdminSettings } from "@/services/api/admin"; import { useUserStore } from "@/stores/use-user-store"; const CodeMirror = dynamic(() => import("@uiw/react-codemirror"), { ssr: false }); @@ -43,21 +43,37 @@ const emptySettings: AdminSettings = { defaultImageModel: "", defaultTextModel: "", systemPrompt: "", - allowCustomModel: false, + allowCustomChannel: false, }, private: { channels: [] }, }; +const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" }; + +type SettingsTabKey = "public" | "private"; +type EditorMode = "visual" | "json"; export default function AdminSettingsPage() { const token = useUserStore((state) => state.token); const { message } = App.useApp(); const [form] = Form.useForm(); - const [mode, setMode] = useState<"visual" | "json">("visual"); - const [jsonText, setJsonText] = useState(""); + const [activeTab, setActiveTab] = useState("public"); + const [editorMode, setEditorMode] = useState>({ public: "visual", private: "visual" }); + const [jsonText, setJsonText] = useState>({ public: "", private: "" }); + const [channels, setChannels] = useState([]); + const [channelForm] = Form.useForm(); + const [editingChannelIndex, setEditingChannelIndex] = useState(null); + const [isChannelDrawerOpen, setIsChannelDrawerOpen] = useState(false); + const [testChannelIndex, setTestChannelIndex] = useState(null); + const [testKeyword, setTestKeyword] = useState(""); + const [selectedTestModels, setSelectedTestModels] = useState([]); + const [testingModels, setTestingModels] = useState([]); + const [testResults, setTestResults] = useState>({}); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const models = Form.useWatch(["public", "availableModels"], form) || []; - const jsonError = mode === "json" ? getJsonError(jsonText) : ""; + const activeMode = editorMode[activeTab]; + const activeJsonText = jsonText[activeTab]; + const jsonError = activeMode === "json" ? getJsonError(activeJsonText) : ""; const loadSettings = async () => { if (!token) return; @@ -65,7 +81,11 @@ export default function AdminSettingsPage() { try { const data = normalizeSettings(await fetchAdminSettings(token)); form.setFieldsValue(data); - setJsonText(JSON.stringify(data, null, 2)); + setChannels(data.private.channels); + setJsonText({ + public: JSON.stringify(data.public, null, 2), + private: JSON.stringify(data.private, null, 2), + }); } catch (error) { message.error(error instanceof Error ? error.message : "读取设置失败"); } finally { @@ -77,33 +97,25 @@ export default function AdminSettingsPage() { void loadSettings(); }, [token]); - const changeMode = (nextMode: "visual" | "json") => { - if (nextMode === "json") { - const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings); - setJsonText(JSON.stringify(values, null, 2)); - } else { - const parsed = parseJsonSettings(jsonText); - if (!parsed) { - message.error("JSON 格式不正确"); - return; - } - form.setFieldsValue(parsed); - } - setMode(nextMode); + const changeTab = (nextTab: SettingsTabKey) => { + setActiveTab(nextTab); }; const saveSettings = async () => { if (!token) return; - const values = mode === "json" ? parseJsonSettings(jsonText) : normalizeSettings(await form.validateFields()); + const values = await collectSettings(form, editorMode, jsonText, message); if (!values) { - message.error("JSON 格式不正确"); return; } setIsSaving(true); try { const saved = normalizeSettings(await saveAdminSettings(token, values)); form.setFieldsValue(saved); - setJsonText(JSON.stringify(saved, null, 2)); + setChannels(saved.private.channels); + setJsonText({ + public: JSON.stringify(saved.public, null, 2), + private: JSON.stringify(saved.private, null, 2), + }); message.success("已保存"); } catch (error) { message.error(error instanceof Error ? error.message : "保存失败"); @@ -112,92 +124,305 @@ export default function AdminSettingsPage() { } }; - const formatJson = () => { - const parsed = parseJsonSettings(jsonText); + const toggleMode = (tab: SettingsTabKey, nextMode: EditorMode) => { + if (nextMode === "json") { + setJsonText((current) => ({ + ...current, + [tab]: JSON.stringify(tab === "public" ? normalizePublicSetting(form.getFieldValue(["public"]) as Partial) : normalizePrivateSetting(form.getFieldValue(["private"]) as Partial), null, 2), + })); + setEditorMode((current) => ({ ...current, [tab]: nextMode })); + return; + } + const parsed = parseTabJson(tab, jsonText[tab]); if (!parsed) { message.error("JSON 格式不正确"); return; } - setJsonText(JSON.stringify(parsed, null, 2)); + form.setFieldsValue({ [tab]: parsed } as Partial); + if (tab === "private") setChannels((parsed as AdminSettings["private"]).channels); + setEditorMode((current) => ({ ...current, [tab]: nextMode })); }; + const formatJson = (tab: SettingsTabKey) => { + const parsed = parseTabJson(tab, jsonText[tab]); + if (!parsed) { + message.error("JSON 格式不正确"); + return; + } + setJsonText((current) => ({ + ...current, + [tab]: JSON.stringify(parsed, null, 2), + })); + }; + + const openChannelDrawer = (index: number | null) => { + setEditingChannelIndex(index); + setIsChannelDrawerOpen(true); + channelForm.setFieldsValue(index === null ? emptyChannel : normalizeChannel(channels[index])); + }; + + const closeChannelDrawer = () => { + setIsChannelDrawerOpen(false); + setEditingChannelIndex(null); + channelForm.resetFields(); + }; + + const saveChannel = async () => { + const channel = normalizeChannel(await channelForm.validateFields()); + const nextChannels = [...channels]; + if (editingChannelIndex === null) nextChannels.push(channel); + else nextChannels[editingChannelIndex] = channel; + setChannels(nextChannels); + form.setFieldValue(["private", "channels"], nextChannels); + closeChannelDrawer(); + }; + + const fetchChannelModelList = async () => { + const channel = channelForm.getFieldsValue(); + if (!channel?.baseUrl || !channel?.apiKey) { + message.warning("请先填写接口地址和 API Key"); + return; + } + try { + const channelModels = await fetchChannelModels(channel); + channelForm.setFieldValue("models", channelModels); + message.success(`已获取 ${channelModels.length} 个模型`); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取模型失败"); + } + }; + + const openTestDialog = (index: number) => { + const channel = normalizeChannel(channels[index]); + if (!channel.baseUrl || !channel.apiKey || channel.models.length === 0) { + message.warning("请先填写接口地址、API Key 和至少一个模型"); + return; + } + setTestChannelIndex(index); + setTestKeyword(""); + setSelectedTestModels([]); + setTestingModels([]); + setTestResults({}); + }; + + const closeTestDialog = () => { + setTestChannelIndex(null); + setTestKeyword(""); + setSelectedTestModels([]); + setTestingModels([]); + setTestResults({}); + }; + + const testModelOnline = async (model: string) => { + if (testChannelIndex === null) return; + const channel = normalizeChannel(channels[testChannelIndex]); + setTestingModels((current) => [...current, model]); + try { + const startedAt = performance.now(); + const result = await testChannelModel(channel, model); + setTestResults((current) => ({ ...current, [model]: { status: "success", duration: `${((performance.now() - startedAt) / 1000).toFixed(2)}s`, message: result } })); + } catch (error) { + setTestResults((current) => ({ ...current, [model]: { status: "error", message: error instanceof Error ? error.message : "测试失败" } })); + } finally { + setTestingModels((current) => current.filter((item) => item !== model)); + } + }; + + const batchTestModels = async () => { + for (const model of selectedTestModels) { + await testModelOnline(model); + } + }; + + const testChannel = testChannelIndex === null ? null : normalizeChannel(channels[testChannelIndex]); + const testModels = (testChannel?.models || []).filter((model) => model.toLowerCase().includes(testKeyword.trim().toLowerCase())); + return (
+ changeTab(key as SettingsTabKey)} + items={[ + { key: "public", label: "公开配置(对外暴露)" }, + { key: "private", label: "私有配置(不会对外暴露)" }, + ]} + /> - changeMode(value as "visual" | "json")} options={[{ label: "可视化编辑", value: "visual" }, { label: "手动编辑 JSON", value: "json" }]} /> + - - {mode === "visual" ? ( -
- - - ({ label: item, value: item }))} /> - ({ label: item, value: item }))} /> - - - - - - - - {(fields, { add, remove }) => ( - - {fields.map((field) => ( -
- - {`渠道 ${field.name + 1}`} -