diff --git a/docs/pending-test.md b/docs/pending-test.md index ea3d339..dc7f0d0 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,3 +1,6 @@ # 待测试 - 修复生图工作台和画布生图请求中 `quality` 参数可能传入上游不支持值导致 400 的问题;请求前会归一化质量枚举,`auto` 或异常值不再发送给上游。 +- 管理后台新增/编辑渠道时,渠道可用模型支持通过弹窗按“新获取、已有”分组选择,并可在弹窗内手动增加模型或拉取模型列表后再写回表单。 +- 管理后台编辑渠道时,API Key 留空不再触发必填校验,表示沿用已保存的密钥;新增渠道仍要求填写 API Key。 +- 管理后台公开配置里的系统可用模型候选项改为由已启用渠道中选择的模型合并去重生成,最终开放哪些模型仍由公开配置里手动勾选。 diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx index 2493467..7ca449e 100644 --- a/web/src/app/(admin)/admin/settings/page.tsx +++ b/web/src/app/(admin)/admin/settings/page.tsx @@ -2,7 +2,7 @@ import { CheckCircleOutlined, DeleteOutlined, FormatPainterOutlined, LoadingOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons"; import { json } from "@codemirror/lang-json"; -import { App, Button, Card, Col, Drawer, Flex, Form, Input, InputNumber, Modal, Row, Segmented, Select, Space, Switch, Table, Tabs, Tag, Typography } from "antd"; +import { App, Button, Card, Checkbox, 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, useMemo, useState } from "react"; import { EditorView } from "@uiw/react-codemirror"; @@ -44,6 +44,7 @@ const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: type SettingsTabKey = "public" | "private"; type EditorMode = "visual" | "json"; +type ModelSelectTabKey = "new" | "current"; export default function AdminSettingsPage() { const token = useUserStore((state) => state.token); @@ -61,6 +62,14 @@ export default function AdminSettingsPage() { const [selectedTestModels, setSelectedTestModels] = useState([]); const [testingModels, setTestingModels] = useState([]); const [testResults, setTestResults] = useState>({}); + const [isModelSelectorOpen, setIsModelSelectorOpen] = useState(false); + const [modelSelectSource, setModelSelectSource] = useState([]); + const [modelSelectExisting, setModelSelectExisting] = useState([]); + const [modelSelectSelected, setModelSelectSelected] = useState([]); + const [modelSelectKeyword, setModelSelectKeyword] = useState(""); + const [modelSelectNewModel, setModelSelectNewModel] = useState(""); + const [modelSelectTab, setModelSelectTab] = useState("new"); + const [isFetchingChannelModels, setIsFetchingChannelModels] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [modelCosts, setModelCosts] = useState([]); @@ -68,10 +77,15 @@ export default function AdminSettingsPage() { const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || []; const channelModels = useMemo(() => collectChannelModels(channels), [channels]); const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [channels]); - const modelOptions = useMemo(() => uniqueModels([...knownModels, ...publicModels, ...channelModels, ...modelCosts.map((item) => item.model)]), [knownModels, publicModels, channelModels, modelCosts]); const activeMode = editorMode[activeTab]; const activeJsonText = jsonText[activeTab]; const jsonError = activeMode === "json" ? getJsonError(activeJsonText) : ""; + const modelSelectGroups = useMemo(() => buildModelSelectGroups(modelSelectSource, modelSelectExisting), [modelSelectSource, modelSelectExisting]); + const activeModelSelectModels = useMemo(() => { + const keyword = modelSelectKeyword.trim().toLowerCase(); + return modelSelectGroups[modelSelectTab].filter((model) => model.toLowerCase().includes(keyword)); + }, [modelSelectGroups, modelSelectKeyword, modelSelectTab]); + const activeSelectedCount = activeModelSelectModels.filter((model) => modelSelectSelected.includes(model)).length; const loadSettings = async () => { if (!token) return; @@ -196,16 +210,73 @@ export default function AdminSettingsPage() { message.warning("请先填写 API Key"); return; } + setIsFetchingChannelModels(true); try { const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) }); - channelForm.setFieldValue("models", channelModels); + const current = isModelSelectorOpen ? uniqueModels(modelSelectSelected) : uniqueModels(channelForm.getFieldValue("models") || []); rememberModels(channelModels); - message.success(`已获取 ${channelModels.length} 个模型`); + setModelSelectExisting(current); + setModelSelectSource(uniqueModels(channelModels)); + setModelSelectSelected(uniqueModels([...current, ...channelModels])); + setModelSelectKeyword(""); + setModelSelectNewModel(""); + setModelSelectTab("new"); + setIsModelSelectorOpen(true); + message.success(`已获取 ${channelModels.length} 个模型,请选择后确认`); } catch (error) { message.error(error instanceof Error ? error.message : "读取模型失败"); + } finally { + setIsFetchingChannelModels(false); } }; + const openChannelModelSelector = (sourceModels?: string[]) => { + const current = uniqueModels(channelForm.getFieldValue("models") || []); + const source = uniqueModels(sourceModels !== undefined ? sourceModels : [...knownModels, ...current]); + setModelSelectExisting(current); + setModelSelectSource(source); + setModelSelectSelected(sourceModels ? uniqueModels([...current, ...source]) : current); + setModelSelectKeyword(""); + setModelSelectNewModel(""); + setModelSelectTab(sourceModels ? "new" : "current"); + setIsModelSelectorOpen(true); + }; + + const closeChannelModelSelector = () => { + setIsModelSelectorOpen(false); + setModelSelectKeyword(""); + setModelSelectNewModel(""); + }; + + const confirmChannelModelSelector = () => { + const models = uniqueModels(modelSelectSelected); + channelForm.setFieldValue("models", models); + rememberModels(models); + closeChannelModelSelector(); + }; + + const toggleSelectedModel = (model: string, checked: boolean) => { + setModelSelectSelected((current) => (checked ? uniqueModels([...current, model]) : current.filter((item) => item !== model))); + }; + + const selectActiveModels = () => { + setModelSelectSelected((current) => uniqueModels([...current, ...activeModelSelectModels])); + }; + + const clearActiveModels = () => { + const active = new Set(activeModelSelectModels); + setModelSelectSelected((current) => current.filter((model) => !active.has(model))); + }; + + const addModelInSelector = () => { + const model = modelSelectNewModel.trim(); + if (!model) return; + setModelSelectExisting((current) => uniqueModels([...current, model])); + setModelSelectSelected((current) => uniqueModels([...current, model])); + setModelSelectNewModel(""); + setModelSelectTab("current"); + }; + function rememberModels(models: string[]) { setKnownModels((current) => uniqueModels([...current, ...models])); } @@ -263,8 +334,10 @@ export default function AdminSettingsPage() { async function persistChannels(nextChannels: AdminModelChannel[]) { if (!token) return; const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings); + const nextChannelModels = collectChannelModels(nextChannels); const nextSettings = normalizeSettings({ ...values, + public: { ...values.public, modelChannel: { ...values.public.modelChannel, availableModels: filterModels(values.public.modelChannel.availableModels, nextChannelModels) } }, private: { ...values.private, channels: nextChannels }, }); const saved = normalizeSettings(await saveAdminSettings(token, nextSettings)); @@ -337,8 +410,8 @@ export default function AdminSettingsPage() {
- - ({ label: item, value: item }))} /> @@ -579,8 +652,8 @@ export default function AdminSettingsPage() { - - + + @@ -589,9 +662,7 @@ export default function AdminSettingsPage() { setModelSelectNewModel(event.target.value)} onPressEnter={addModelInSelector} /> + + + + + setModelSelectTab(key as ModelSelectTabKey)} + items={[ + { key: "new", label: `新获取的模型 (${modelSelectGroups.new.length})` }, + { key: "current", label: `已有的模型 (${modelSelectGroups.current.length})` }, + ]} + /> + + + 当前列表已选择 {activeSelectedCount} / {activeModelSelectModels.length} + + + + + + +
+ {activeModelSelectModels.length ? ( +
+ {activeModelSelectModels.map((model) => ( + toggleSelectedModel(model, event.target.checked)}> + {model} + + ))} +
+ ) : ( +
+ 没有匹配的模型 +
+ )} +
+ + @@ -770,10 +912,25 @@ function collectKnownModels(settings: AdminSettings) { ]); } +function buildModelSelectGroups(sourceModels: string[], existingModels: string[]): Record { + const source = uniqueModels(sourceModels); + const existing = uniqueModels(existingModels); + const existingSet = new Set(existing); + return { + new: source.filter((model) => !existingSet.has(model)), + current: existing, + }; +} + function uniqueModels(models: string[]) { return Array.from(new Set(models.filter(Boolean))); } +function filterModels(models: string[], options: string[]) { + const optionSet = new Set(options); + return uniqueModels(models).filter((model) => optionSet.has(model)); +} + function modelSummary(models: string[]) { if (!models.length) return "未配置模型"; const preview = models.slice(0, 3).join(", "); @@ -809,6 +966,7 @@ async function collectSettings(form: any, editorMode: Record