feat(admin): 重构系统设置页面并新增渠道管理功能
- 将系统设置页面改为公开/私有双标签页结构 - 新增渠道协议、权重配置和加权随机选择算法 - 替换自定义模型为自定义渠道配置选项 - 实现渠道模型远程获取和在线测试功能 - 添加批量测试和抽屉式编辑界面 - 更新文档说明和数据表结构定义
This commit is contained in:
+2
-1
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
+ [新增] 管理后台新增系统设置页面,支持可视化编辑和手动编辑 JSON。
|
+ [新增] 管理后台新增系统设置页面,按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON。
|
||||||
|
+ [调整] 模型渠道配置改为列表和抽屉编辑,新增协议、权重、远程获取模型列表、模型清单测试和批量测试能力。
|
||||||
+ [新增] 后端新增 `settings` 表和系统设置接口,支持 public/private 两行 JSON 配置。
|
+ [新增] 后端新增 `settings` 表和系统设置接口,支持 public/private 两行 JSON 配置。
|
||||||
+ [修复] Docker 构建和运行阶段补充拷贝 `CHANGELOG.md`,避免版本信息读取失败。
|
+ [修复] Docker 构建和运行阶段补充拷贝 `CHANGELOG.md`,避免版本信息读取失败。
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
| `defaultImageModel` | string | 默认图片模型 |
|
| `defaultImageModel` | string | 默认图片模型 |
|
||||||
| `defaultTextModel` | string | 默认文本模型 |
|
| `defaultTextModel` | string | 默认文本模型 |
|
||||||
| `systemPrompt` | string | 系统提示词 |
|
| `systemPrompt` | string | 系统提示词 |
|
||||||
| `allowCustomModel` | bool | 是否允许用户自定义模型 |
|
| `allowCustomChannel` | bool | 是否允许用户自定义渠道,允许时前端可提供走后端渠道和自定义 baseUrl 直连两种模式 |
|
||||||
|
|
||||||
`private.value` 当前字段:
|
`private.value` 当前字段:
|
||||||
|
|
||||||
@@ -128,14 +128,17 @@
|
|||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|----------|----------|----------|
|
|----------|----------|----------|
|
||||||
| `key` | string | 渠道标识 |
|
| `protocol` | string | 协议,当前支持 `openai` |
|
||||||
| `name` | string | 渠道名称 |
|
| `name` | string | 渠道名称 |
|
||||||
| `baseUrl` | string | 渠道接口地址 |
|
| `baseUrl` | string | 渠道接口地址 |
|
||||||
| `apiKey` | string | 渠道密钥 |
|
| `apiKey` | string | 渠道密钥 |
|
||||||
| `models` | string[] | 渠道可用模型列表 |
|
| `models` | string[] | 渠道可用模型列表 |
|
||||||
|
| `weight` | number | 渠道权重,同一模型命中多个渠道时按权重随机 |
|
||||||
| `enabled` | bool | 是否启用 |
|
| `enabled` | bool | 是否启用 |
|
||||||
| `remark` | string | 备注 |
|
| `remark` | string | 备注 |
|
||||||
|
|
||||||
|
后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。
|
||||||
|
|
||||||
### dicts
|
### dicts
|
||||||
|
|
||||||
字典表。一个字典一行,具体字典项数据放在 `items`。
|
字典表。一个字典一行,具体字典项数据放在 `items`。
|
||||||
|
|||||||
@@ -4,4 +4,5 @@
|
|||||||
- 设置弹窗中填写系统提示词后,生图、编辑图和画布助手文本请求应携带该提示词。
|
- 设置弹窗中填写系统提示词后,生图、编辑图和画布助手文本请求应携带该提示词。
|
||||||
- Docker 镜像构建和运行时,前端应能正常读取 `CHANGELOG.md` 并完成版本信息注入。
|
- Docker 镜像构建和运行时,前端应能正常读取 `CHANGELOG.md` 并完成版本信息注入。
|
||||||
- 后端应自动维护 `settings` 表,并支持公开读取 public 配置、管理员读取和保存 public/private 两行 JSON 配置。
|
- 后端应自动维护 `settings` 表,并支持公开读取 public 配置、管理员读取和保存 public/private 两行 JSON 配置。
|
||||||
- 管理后台系统设置页面应支持可视化编辑和手动编辑 JSON,并能保存 public/private 配置。
|
- 管理后台系统设置页面应按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON,且可保存 public/private 配置。
|
||||||
|
- 管理后台私有配置中的模型渠道应以列表展示、通过抽屉新增编辑,并支持填写权重、远程获取模型列表、模型清单测试和批量测试。
|
||||||
|
|||||||
+37
-13
@@ -11,23 +11,24 @@ const (
|
|||||||
|
|
||||||
// ModelChannel 模型渠道配置。
|
// ModelChannel 模型渠道配置。
|
||||||
type ModelChannel struct {
|
type ModelChannel struct {
|
||||||
Key string `json:"key"`
|
Protocol string `json:"protocol"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
BaseURL string `json:"baseUrl"`
|
BaseURL string `json:"baseUrl"`
|
||||||
APIKey string `json:"apiKey"`
|
APIKey string `json:"apiKey"`
|
||||||
Models []string `json:"models"`
|
Models []string `json:"models"`
|
||||||
Enabled bool `json:"enabled"`
|
Weight int `json:"weight"`
|
||||||
Remark string `json:"remark"`
|
Enabled bool `json:"enabled"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicSetting 公开配置。
|
// PublicSetting 公开配置。
|
||||||
type PublicSetting struct {
|
type PublicSetting struct {
|
||||||
AvailableModels []string `json:"availableModels"`
|
AvailableModels []string `json:"availableModels"`
|
||||||
DefaultModel string `json:"defaultModel"`
|
DefaultModel string `json:"defaultModel"`
|
||||||
DefaultImageModel string `json:"defaultImageModel"`
|
DefaultImageModel string `json:"defaultImageModel"`
|
||||||
DefaultTextModel string `json:"defaultTextModel"`
|
DefaultTextModel string `json:"defaultTextModel"`
|
||||||
SystemPrompt string `json:"systemPrompt"`
|
SystemPrompt string `json:"systemPrompt"`
|
||||||
AllowCustomModel bool `json:"allowCustomModel"`
|
AllowCustomChannel bool `json:"allowCustomChannel"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrivateSetting 私有配置。
|
// PrivateSetting 私有配置。
|
||||||
@@ -48,3 +49,26 @@ type Settings struct {
|
|||||||
Public PublicSetting `json:"public"`
|
Public PublicSetting `json:"public"`
|
||||||
Private PrivateSetting `json:"private"`
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/basketikun/infinite-canvas/model"
|
"github.com/basketikun/infinite-canvas/model"
|
||||||
"github.com/basketikun/infinite-canvas/repository"
|
"github.com/basketikun/infinite-canvas/repository"
|
||||||
)
|
)
|
||||||
@@ -37,9 +41,54 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting
|
|||||||
setting.Channels = []model.ModelChannel{}
|
setting.Channels = []model.ModelChannel{}
|
||||||
}
|
}
|
||||||
for i := range setting.Channels {
|
for i := range setting.Channels {
|
||||||
|
if setting.Channels[i].Protocol == "" {
|
||||||
|
setting.Channels[i].Protocol = "openai"
|
||||||
|
}
|
||||||
if setting.Channels[i].Models == nil {
|
if setting.Channels[i].Models == nil {
|
||||||
setting.Channels[i].Models = []string{}
|
setting.Channels[i].Models = []string{}
|
||||||
}
|
}
|
||||||
|
if setting.Channels[i].Weight <= 0 {
|
||||||
|
setting.Channels[i].Weight = 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return setting
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client";
|
"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 { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||||
import { json } from "@codemirror/lang-json";
|
import { json } from "@codemirror/lang-json";
|
||||||
import { tags } from "@lezer/highlight";
|
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 dynamic from "next/dynamic";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { EditorView } from "@uiw/react-codemirror";
|
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";
|
import { useUserStore } from "@/stores/use-user-store";
|
||||||
|
|
||||||
const CodeMirror = dynamic(() => import("@uiw/react-codemirror"), { ssr: false });
|
const CodeMirror = dynamic(() => import("@uiw/react-codemirror"), { ssr: false });
|
||||||
@@ -43,21 +43,37 @@ const emptySettings: AdminSettings = {
|
|||||||
defaultImageModel: "",
|
defaultImageModel: "",
|
||||||
defaultTextModel: "",
|
defaultTextModel: "",
|
||||||
systemPrompt: "",
|
systemPrompt: "",
|
||||||
allowCustomModel: false,
|
allowCustomChannel: false,
|
||||||
},
|
},
|
||||||
private: { channels: [] },
|
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() {
|
export default function AdminSettingsPage() {
|
||||||
const token = useUserStore((state) => state.token);
|
const token = useUserStore((state) => state.token);
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [form] = Form.useForm<AdminSettings>();
|
const [form] = Form.useForm<AdminSettings>();
|
||||||
const [mode, setMode] = useState<"visual" | "json">("visual");
|
const [activeTab, setActiveTab] = useState<SettingsTabKey>("public");
|
||||||
const [jsonText, setJsonText] = useState("");
|
const [editorMode, setEditorMode] = useState<Record<SettingsTabKey, EditorMode>>({ public: "visual", private: "visual" });
|
||||||
|
const [jsonText, setJsonText] = useState<Record<SettingsTabKey, string>>({ public: "", private: "" });
|
||||||
|
const [channels, setChannels] = useState<AdminModelChannel[]>([]);
|
||||||
|
const [channelForm] = Form.useForm<AdminModelChannel>();
|
||||||
|
const [editingChannelIndex, setEditingChannelIndex] = useState<number | null>(null);
|
||||||
|
const [isChannelDrawerOpen, setIsChannelDrawerOpen] = useState(false);
|
||||||
|
const [testChannelIndex, setTestChannelIndex] = useState<number | null>(null);
|
||||||
|
const [testKeyword, setTestKeyword] = useState("");
|
||||||
|
const [selectedTestModels, setSelectedTestModels] = useState<string[]>([]);
|
||||||
|
const [testingModels, setTestingModels] = useState<string[]>([]);
|
||||||
|
const [testResults, setTestResults] = useState<Record<string, { status: "success" | "error"; duration?: string; message: string }>>({});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const models = Form.useWatch(["public", "availableModels"], form) || [];
|
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 () => {
|
const loadSettings = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -65,7 +81,11 @@ export default function AdminSettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const data = normalizeSettings(await fetchAdminSettings(token));
|
const data = normalizeSettings(await fetchAdminSettings(token));
|
||||||
form.setFieldsValue(data);
|
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) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : "读取设置失败");
|
message.error(error instanceof Error ? error.message : "读取设置失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -77,33 +97,25 @@ export default function AdminSettingsPage() {
|
|||||||
void loadSettings();
|
void loadSettings();
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const changeMode = (nextMode: "visual" | "json") => {
|
const changeTab = (nextTab: SettingsTabKey) => {
|
||||||
if (nextMode === "json") {
|
setActiveTab(nextTab);
|
||||||
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 saveSettings = async () => {
|
const saveSettings = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const values = mode === "json" ? parseJsonSettings(jsonText) : normalizeSettings(await form.validateFields());
|
const values = await collectSettings(form, editorMode, jsonText, message);
|
||||||
if (!values) {
|
if (!values) {
|
||||||
message.error("JSON 格式不正确");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const saved = normalizeSettings(await saveAdminSettings(token, values));
|
const saved = normalizeSettings(await saveAdminSettings(token, values));
|
||||||
form.setFieldsValue(saved);
|
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("已保存");
|
message.success("已保存");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : "保存失败");
|
message.error(error instanceof Error ? error.message : "保存失败");
|
||||||
@@ -112,92 +124,305 @@ export default function AdminSettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatJson = () => {
|
const toggleMode = (tab: SettingsTabKey, nextMode: EditorMode) => {
|
||||||
const parsed = parseJsonSettings(jsonText);
|
if (nextMode === "json") {
|
||||||
|
setJsonText((current) => ({
|
||||||
|
...current,
|
||||||
|
[tab]: JSON.stringify(tab === "public" ? normalizePublicSetting(form.getFieldValue(["public"]) as Partial<AdminSettings["public"]>) : normalizePrivateSetting(form.getFieldValue(["private"]) as Partial<AdminSettings["private"]>), null, 2),
|
||||||
|
}));
|
||||||
|
setEditorMode((current) => ({ ...current, [tab]: nextMode }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = parseTabJson(tab, jsonText[tab]);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
message.error("JSON 格式不正确");
|
message.error("JSON 格式不正确");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setJsonText(JSON.stringify(parsed, null, 2));
|
form.setFieldsValue({ [tab]: parsed } as Partial<AdminSettings>);
|
||||||
|
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 (
|
return (
|
||||||
<main style={{ padding: 24 }}>
|
<main style={{ padding: 24 }}>
|
||||||
<Flex vertical gap={16}>
|
<Flex vertical gap={16}>
|
||||||
<Card variant="borderless">
|
<Card variant="borderless">
|
||||||
<Flex justify="space-between" align="center" gap={16} wrap>
|
<Flex justify="space-between" align="center" gap={16} wrap>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={(key) => changeTab(key as SettingsTabKey)}
|
||||||
|
items={[
|
||||||
|
{ key: "public", label: "公开配置(对外暴露)" },
|
||||||
|
{ key: "private", label: "私有配置(不会对外暴露)" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Space>
|
<Space>
|
||||||
<Segmented value={mode} onChange={(value) => changeMode(value as "visual" | "json")} options={[{ label: "可视化编辑", value: "visual" }, { label: "手动编辑 JSON", value: "json" }]} />
|
|
||||||
<Button icon={<ReloadOutlined />} loading={isLoading} onClick={() => void loadSettings()}>刷新</Button>
|
<Button icon={<ReloadOutlined />} loading={isLoading} onClick={() => void loadSettings()}>刷新</Button>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} loading={isSaving} onClick={() => void saveSettings()}>保存设置</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Button type="primary" icon={<SaveOutlined />} loading={isSaving} onClick={() => void saveSettings()}>保存设置</Button>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{mode === "visual" ? (
|
<Card variant="borderless">
|
||||||
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
|
<Flex justify="space-between" align="center" gap={16} wrap style={{ marginBottom: 16 }}>
|
||||||
<Card title="公开配置" variant="borderless" style={{ marginBottom: 16 }}>
|
<Segmented
|
||||||
<Row gutter={16}>
|
value={activeMode}
|
||||||
<Col span={24}><Form.Item name={["public", "availableModels"]} label="系统可用模型"><Select mode="tags" tokenSeparators={[",", "\n"]} options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
onChange={(value) => toggleMode(activeTab, value as EditorMode)}
|
||||||
<Col xs={24} md={8}><Form.Item name={["public", "defaultModel"]} label="默认模型"><Select showSearch allowClear options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
options={[{ label: "可视化编辑", value: "visual" }, { label: "手动编辑 JSON", value: "json" }]}
|
||||||
<Col xs={24} md={8}><Form.Item name={["public", "defaultImageModel"]} label="默认图片模型"><Select showSearch allowClear options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
/>
|
||||||
<Col xs={24} md={8}><Form.Item name={["public", "defaultTextModel"]} label="默认文本模型"><Select showSearch allowClear options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
{activeMode === "json" ? (
|
||||||
<Col span={24}><Form.Item name={["public", "systemPrompt"]} label="系统提示词"><Input.TextArea rows={4} /></Form.Item></Col>
|
|
||||||
<Col span={24}><Form.Item name={["public", "allowCustomModel"]} label="允许用户自定义模型" valuePropName="checked"><Switch /></Form.Item></Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="模型渠道" variant="borderless">
|
|
||||||
<Form.List name={["private", "channels"]}>
|
|
||||||
{(fields, { add, remove }) => (
|
|
||||||
<Flex vertical gap={12}>
|
|
||||||
{fields.map((field) => (
|
|
||||||
<div key={field.key} style={{ border: "1px solid var(--ant-color-border)", borderRadius: 6, padding: 16 }}>
|
|
||||||
<Flex justify="space-between" align="center" style={{ marginBottom: 12 }}>
|
|
||||||
<Typography.Text strong>{`渠道 ${field.name + 1}`}</Typography.Text>
|
|
||||||
<Button danger type="text" icon={<DeleteOutlined />} onClick={() => remove(field.name)} />
|
|
||||||
</Flex>
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col xs={24} md={8}><Form.Item name={[field.name, "key"]} label="渠道标识"><Input /></Form.Item></Col>
|
|
||||||
<Col xs={24} md={8}><Form.Item name={[field.name, "name"]} label="渠道名称"><Input /></Form.Item></Col>
|
|
||||||
<Col xs={24} md={8}><Form.Item name={[field.name, "enabled"]} label="启用" valuePropName="checked"><Switch /></Form.Item></Col>
|
|
||||||
<Col xs={24} md={12}><Form.Item name={[field.name, "baseUrl"]} label="接口地址"><Input /></Form.Item></Col>
|
|
||||||
<Col xs={24} md={12}><Form.Item name={[field.name, "apiKey"]} label="API Key"><Input.Password /></Form.Item></Col>
|
|
||||||
<Col span={24}><Form.Item name={[field.name, "models"]} label="渠道可用模型"><Select mode="tags" tokenSeparators={[",", "\n"]} /></Form.Item></Col>
|
|
||||||
<Col span={24}><Form.Item name={[field.name, "remark"]} label="备注"><Input.TextArea rows={2} /></Form.Item></Col>
|
|
||||||
</Row>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button icon={<PlusOutlined />} onClick={() => add({ key: "", name: "", baseUrl: "", apiKey: "", models: [], enabled: true, remark: "" })}>新增渠道</Button>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Form.List>
|
|
||||||
</Card>
|
|
||||||
</Form>
|
|
||||||
) : (
|
|
||||||
<Card variant="borderless">
|
|
||||||
<Flex justify="space-between" align="center" gap={12} wrap style={{ marginBottom: 12 }}>
|
|
||||||
<Space>
|
<Space>
|
||||||
<CodeOutlined />
|
|
||||||
<Typography.Text strong>完整配置 JSON</Typography.Text>
|
|
||||||
{jsonError ? <Tag color="error">{jsonError}</Tag> : <Tag color="success" icon={<CheckCircleOutlined />}>JSON 格式正确</Tag>}
|
{jsonError ? <Tag color="error">{jsonError}</Tag> : <Tag color="success" icon={<CheckCircleOutlined />}>JSON 格式正确</Tag>}
|
||||||
|
<Button icon={<FormatPainterOutlined />} onClick={() => formatJson(activeTab)}>格式化</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Button icon={<FormatPainterOutlined />} onClick={formatJson}>格式化</Button>
|
) : (
|
||||||
</Flex>
|
<Typography.Text type="secondary">{activeTab === "public" ? "这些配置会暴露给前端读取" : "这些配置只会在后台保存"}</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{activeTab === "public" ? (
|
||||||
|
activeMode === "visual" ? (
|
||||||
|
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={24}><Form.Item name={["public", "availableModels"]} label="系统可用模型(请先添加渠道)"><Select mode="tags" tokenSeparators={[",", "\n"]} options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
||||||
|
<Col xs={24} md={8}><Form.Item name={["public", "defaultModel"]} label="默认模型"><Select showSearch allowClear options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
||||||
|
<Col xs={24} md={8}><Form.Item name={["public", "defaultImageModel"]} label="默认图片模型"><Select showSearch allowClear options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
||||||
|
<Col xs={24} md={8}><Form.Item name={["public", "defaultTextModel"]} label="默认文本模型"><Select showSearch allowClear options={models.map((item) => ({ label: item, value: item }))} /></Form.Item></Col>
|
||||||
|
<Col span={24}><Form.Item name={["public", "systemPrompt"]} label="系统提示词"><Input.TextArea rows={4} /></Form.Item></Col>
|
||||||
|
<Col span={24}><Form.Item name={["public", "allowCustomChannel"]} label="是否允许用户自定义渠道" extra="开启后,前端可提供走后端渠道和用户自定义 baseUrl 直连两种模式" valuePropName="checked"><Switch /></Form.Item></Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflow: "hidden", border: "1px solid var(--ant-color-border)", borderRadius: 6 }}>
|
||||||
|
<CodeMirror
|
||||||
|
value={activeJsonText}
|
||||||
|
height="520px"
|
||||||
|
extensions={[json(), jsonEditorTheme, syntaxHighlighting(jsonHighlightStyle)]}
|
||||||
|
basicSetup={{ foldGutter: true, lineNumbers: true, highlightActiveLine: true, highlightActiveLineGutter: true }}
|
||||||
|
theme="none"
|
||||||
|
onChange={(value) => setJsonText((current) => ({ ...current, public: value }))}
|
||||||
|
className="admin-json-editor"
|
||||||
|
style={{ fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : activeMode === "visual" ? (
|
||||||
|
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
|
||||||
|
<Flex vertical gap={12}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => openChannelDrawer(null)}>新增渠道</Button>
|
||||||
|
<Table
|
||||||
|
rowKey={(_, index) => String(index)}
|
||||||
|
pagination={false}
|
||||||
|
dataSource={channels}
|
||||||
|
columns={[
|
||||||
|
{ title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" },
|
||||||
|
{ title: "协议", dataIndex: "protocol", width: 96, render: (value) => <Tag>{value || "openai"}</Tag> },
|
||||||
|
{ title: "状态", dataIndex: "enabled", width: 96, render: (value) => <Tag color={value ? "success" : "default"}>{value ? "已启用" : "已停用"}</Tag> },
|
||||||
|
{ title: "模型", dataIndex: "models", render: (value: string[]) => <Typography.Text ellipsis style={{ maxWidth: 360 }}>{modelSummary(value || [])}</Typography.Text> },
|
||||||
|
{ title: "权重", dataIndex: "weight", width: 88 },
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "actions",
|
||||||
|
width: 220,
|
||||||
|
align: "right",
|
||||||
|
render: (_, __, index) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Button size="small" onClick={() => openTestDialog(index)}>测试</Button>
|
||||||
|
<Button size="small" onClick={() => openChannelDrawer(index)}>编辑</Button>
|
||||||
|
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => {
|
||||||
|
const nextChannels = [...channels];
|
||||||
|
nextChannels.splice(index, 1);
|
||||||
|
setChannels(nextChannels);
|
||||||
|
form.setFieldValue(["private", "channels"], nextChannels);
|
||||||
|
}} />
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
<div style={{ overflow: "hidden", border: "1px solid var(--ant-color-border)", borderRadius: 6 }}>
|
<div style={{ overflow: "hidden", border: "1px solid var(--ant-color-border)", borderRadius: 6 }}>
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
value={jsonText}
|
value={activeJsonText}
|
||||||
height="560px"
|
height="520px"
|
||||||
extensions={[json(), jsonEditorTheme, syntaxHighlighting(jsonHighlightStyle)]}
|
extensions={[json(), jsonEditorTheme, syntaxHighlighting(jsonHighlightStyle)]}
|
||||||
basicSetup={{ foldGutter: true, lineNumbers: true, highlightActiveLine: true, highlightActiveLineGutter: true }}
|
basicSetup={{ foldGutter: true, lineNumbers: true, highlightActiveLine: true, highlightActiveLineGutter: true }}
|
||||||
theme="none"
|
theme="none"
|
||||||
onChange={setJsonText}
|
onChange={(value) => setJsonText((current) => ({ ...current, private: value }))}
|
||||||
className="admin-json-editor"
|
className="admin-json-editor"
|
||||||
style={{ fontSize: 13 }}
|
style={{ fontSize: 13 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
)}
|
</Card>
|
||||||
|
<Drawer title={editingChannelIndex === null ? "新增渠道" : "编辑渠道"} open={isChannelDrawerOpen} width={560} onClose={closeChannelDrawer} extra={<Space><Button onClick={closeChannelDrawer}>取消</Button><Button type="primary" onClick={() => void saveChannel()}>保存</Button></Space>} destroyOnHidden>
|
||||||
|
<Form form={channelForm} layout="vertical" requiredMark={false} initialValues={emptyChannel}>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}><Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请输入渠道名称" }]}><Input /></Form.Item></Col>
|
||||||
|
<Col span={12}><Form.Item name="protocol" label="协议"><Select options={[{ label: "OpenAI", value: "openai" }]} /></Form.Item></Col>
|
||||||
|
<Col span={12}><Form.Item name="weight" label="权重"><InputNumber min={1} step={1} className="!w-full" /></Form.Item></Col>
|
||||||
|
<Col span={12}><Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item></Col>
|
||||||
|
<Col span={24}><Form.Item name="baseUrl" label="接口地址" rules={[{ required: true, message: "请输入接口地址" }]}><Input /></Form.Item></Col>
|
||||||
|
<Col span={24}><Form.Item name="apiKey" label="API Key" rules={[{ required: true, message: "请输入 API Key" }]}><Input.Password /></Form.Item></Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Item label="渠道可用模型">
|
||||||
|
<Space.Compact style={{ width: "100%" }}>
|
||||||
|
<Form.Item name="models" noStyle><Select mode="tags" tokenSeparators={[",", "\n"]} /></Form.Item>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void fetchChannelModelList()}>获取模型列表</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}><Form.Item name="remark" label="备注"><Input.TextArea rows={3} /></Form.Item></Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
<Modal
|
||||||
|
title={<Space>{testChannel?.name || "渠道"} 渠道的模型测试<Typography.Text type="secondary">共 {testChannel?.models.length || 0} 个模型</Typography.Text></Space>}
|
||||||
|
open={testChannelIndex !== null}
|
||||||
|
width={920}
|
||||||
|
onCancel={closeTestDialog}
|
||||||
|
footer={<Space><Button onClick={closeTestDialog}>取消</Button><Button type="primary" disabled={!selectedTestModels.length || testingModels.length > 0} onClick={() => void batchTestModels()}>批量测试 {selectedTestModels.length} 个模型</Button></Space>}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Flex vertical gap={12}>
|
||||||
|
<Typography.Text type="secondary">测试会向选中模型发送一条 hi,用于确认渠道是否有响应。</Typography.Text>
|
||||||
|
<Input.Search placeholder="搜索模型..." allowClear value={testKeyword} onChange={(event) => setTestKeyword(event.target.value)} />
|
||||||
|
<Table
|
||||||
|
rowKey="model"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ y: 420 }}
|
||||||
|
dataSource={testModels.map((model) => ({ model }))}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedTestModels,
|
||||||
|
onChange: (keys) => setSelectedTestModels(keys.map(String)),
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{ title: "模型名称", dataIndex: "model", render: (value) => <Typography.Text strong>{value}</Typography.Text> },
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
dataIndex: "model",
|
||||||
|
width: 260,
|
||||||
|
render: (value) => {
|
||||||
|
if (testingModels.includes(value)) return <Tag color="processing">测试中</Tag>;
|
||||||
|
const result = testResults[value];
|
||||||
|
if (!result) return <Tag>未开始</Tag>;
|
||||||
|
return result.status === "success" ? (
|
||||||
|
<Space size={6} wrap>
|
||||||
|
<Tag color="success">成功</Tag>
|
||||||
|
<Typography.Text type="secondary">请求时长: {result.duration}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="danger">{result.message}</Typography.Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "actions",
|
||||||
|
width: 120,
|
||||||
|
align: "right",
|
||||||
|
render: (_, item) => <Button size="small" loading={testingModels.includes(item.model)} onClick={() => void testModelOnline(item.model)}>测试</Button>,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Modal>
|
||||||
</Flex>
|
</Flex>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@@ -206,32 +431,76 @@ export default function AdminSettingsPage() {
|
|||||||
function normalizeSettings(settings: Partial<AdminSettings> = {}): AdminSettings {
|
function normalizeSettings(settings: Partial<AdminSettings> = {}): AdminSettings {
|
||||||
return {
|
return {
|
||||||
public: {
|
public: {
|
||||||
...emptySettings.public,
|
...normalizePublicSetting(settings.public),
|
||||||
...(settings.public || {}),
|
|
||||||
availableModels: settings.public?.availableModels || [],
|
|
||||||
},
|
},
|
||||||
private: {
|
private: {
|
||||||
channels: (settings.private?.channels || []).map((item) => ({
|
...normalizePrivateSetting(settings.private),
|
||||||
key: item.key || "",
|
|
||||||
name: item.name || "",
|
|
||||||
baseUrl: item.baseUrl || "",
|
|
||||||
apiKey: item.apiKey || "",
|
|
||||||
models: item.models || [],
|
|
||||||
enabled: Boolean(item.enabled),
|
|
||||||
remark: item.remark || "",
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJsonSettings(value: string) {
|
function normalizePublicSetting(setting: Partial<AdminSettings["public"]> = {}): AdminSettings["public"] {
|
||||||
|
return {
|
||||||
|
...emptySettings.public,
|
||||||
|
...setting,
|
||||||
|
availableModels: setting.availableModels || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}): AdminSettings["private"] {
|
||||||
|
return {
|
||||||
|
channels: (setting.channels || []).map(normalizeChannel),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChannel(item: Partial<AdminModelChannel> = {}): AdminModelChannel {
|
||||||
|
return {
|
||||||
|
protocol: "openai",
|
||||||
|
name: item.name || "",
|
||||||
|
baseUrl: item.baseUrl || "",
|
||||||
|
apiKey: item.apiKey || "",
|
||||||
|
models: item.models || [],
|
||||||
|
weight: Math.max(1, Number(item.weight) || 1),
|
||||||
|
enabled: item.enabled !== false,
|
||||||
|
remark: item.remark || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelSummary(models: string[]) {
|
||||||
|
if (!models.length) return "未配置模型";
|
||||||
|
const preview = models.slice(0, 3).join(", ");
|
||||||
|
return models.length > 3 ? `${models.length} 个模型:${preview}...` : preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTabJson(tab: SettingsTabKey, value: string) {
|
||||||
try {
|
try {
|
||||||
return normalizeSettings(JSON.parse(value) as AdminSettings);
|
return tab === "public" ? normalizePublicSetting(JSON.parse(value) as Partial<AdminSettings["public"]>) : normalizePrivateSetting(JSON.parse(value) as Partial<AdminSettings["private"]>);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function collectSettings(form: any, editorMode: Record<SettingsTabKey, EditorMode>, jsonText: Record<SettingsTabKey, string>, message: { error: (value: string) => void }) {
|
||||||
|
const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings);
|
||||||
|
if (editorMode.public === "json") {
|
||||||
|
const publicSetting = parseTabJson("public", jsonText.public);
|
||||||
|
if (!publicSetting) {
|
||||||
|
message.error("公开配置 JSON 格式不正确");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
values.public = publicSetting;
|
||||||
|
}
|
||||||
|
if (editorMode.private === "json") {
|
||||||
|
const privateSetting = parseTabJson("private", jsonText.private);
|
||||||
|
if (!privateSetting) {
|
||||||
|
message.error("私有配置 JSON 格式不正确");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
values.private = privateSetting;
|
||||||
|
}
|
||||||
|
return normalizeSettings(values);
|
||||||
|
}
|
||||||
|
|
||||||
function getJsonError(value: string) {
|
function getJsonError(value: string) {
|
||||||
try {
|
try {
|
||||||
JSON.parse(value);
|
JSON.parse(value);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { apiDelete, apiGet, apiPost, compactApiParams } from "@/services/api/request";
|
import { apiDelete, apiGet, apiPost, compactApiParams } from "@/services/api/request";
|
||||||
import type { Prompt, PromptListResponse } from "@/services/api/prompts";
|
import type { Prompt, PromptListResponse } from "@/services/api/prompts";
|
||||||
|
import { buildApiUrl } from "@/lib/ai-config";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
export type AdminPromptCategory = {
|
export type AdminPromptCategory = {
|
||||||
category: string;
|
category: string;
|
||||||
@@ -83,11 +85,12 @@ export async function deleteAdminAsset(token: string, id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AdminModelChannel = {
|
export type AdminModelChannel = {
|
||||||
key: string;
|
protocol: "openai";
|
||||||
name: string;
|
name: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
models: string[];
|
models: string[];
|
||||||
|
weight: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
remark: string;
|
remark: string;
|
||||||
};
|
};
|
||||||
@@ -98,7 +101,7 @@ export type AdminPublicSettings = {
|
|||||||
defaultImageModel: string;
|
defaultImageModel: string;
|
||||||
defaultTextModel: string;
|
defaultTextModel: string;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
allowCustomModel: boolean;
|
allowCustomChannel: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminPrivateSettings = {
|
export type AdminPrivateSettings = {
|
||||||
@@ -117,3 +120,37 @@ export async function fetchAdminSettings(token: string) {
|
|||||||
export async function saveAdminSettings(token: string, settings: AdminSettings) {
|
export async function saveAdminSettings(token: string, settings: AdminSettings) {
|
||||||
return apiPost<AdminSettings>("/api/admin/settings", settings, token);
|
return apiPost<AdminSettings>("/api/admin/settings", settings, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchChannelModels(channel: Pick<AdminModelChannel, "baseUrl" | "apiKey">) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(buildApiUrl(channel.baseUrl, "/models"), {
|
||||||
|
headers: { Authorization: `Bearer ${channel.apiKey}` },
|
||||||
|
});
|
||||||
|
return (response.data.data || [])
|
||||||
|
.map((item) => item.id)
|
||||||
|
.filter((id): id is string => Boolean(id))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(readChannelError(error, "读取模型失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testChannelModel(channel: Pick<AdminModelChannel, "baseUrl" | "apiKey">, model: string) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post<{ choices?: Array<{ message?: { content?: string } }>; error?: { message?: string } }>(
|
||||||
|
buildApiUrl(channel.baseUrl, "/chat/completions"),
|
||||||
|
{ model, messages: [{ role: "user", content: "hi" }] },
|
||||||
|
{ headers: { Authorization: `Bearer ${channel.apiKey}`, "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
return response.data.choices?.[0]?.message?.content || "ok";
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(readChannelError(error, "测试失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChannelError(error: unknown, fallback: string) {
|
||||||
|
if (axios.isAxiosError<{ error?: { message?: string } }>(error)) {
|
||||||
|
return error.response?.data?.error?.message || (error.response?.status ? `${fallback}:${error.response.status}` : fallback);
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user