diff --git a/controller/model_meta.go b/controller/model_meta.go new file mode 100644 index 00000000..9039419d --- /dev/null +++ b/controller/model_meta.go @@ -0,0 +1,143 @@ +package controller + +import ( + "encoding/json" + "strconv" + + "one-api/common" + "one-api/model" + + "github.com/gin-gonic/gin" +) + +// GetAllModelsMeta 获取模型列表(分页) +func GetAllModelsMeta(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + // 填充附加字段 + for _, m := range modelsMeta { + fillModelExtra(m) + } + var total int64 + model.DB.Model(&model.Model{}).Count(&total) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(modelsMeta) + common.ApiSuccess(c, pageInfo) +} + +// SearchModelsMeta 搜索模型列表 +func SearchModelsMeta(c *gin.Context) { + keyword := c.Query("keyword") + vendor := c.Query("vendor") + pageInfo := common.GetPageQuery(c) + + modelsMeta, total, err := model.SearchModels(keyword, vendor, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + for _, m := range modelsMeta { + fillModelExtra(m) + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(modelsMeta) + common.ApiSuccess(c, pageInfo) +} + +// GetModelMeta 根据 ID 获取单条模型信息 +func GetModelMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + var m model.Model + if err := model.DB.First(&m, id).Error; err != nil { + common.ApiError(c, err) + return + } + fillModelExtra(&m) + common.ApiSuccess(c, &m) +} + +// CreateModelMeta 新建模型 +func CreateModelMeta(c *gin.Context) { + var m model.Model + if err := c.ShouldBindJSON(&m); err != nil { + common.ApiError(c, err) + return + } + if m.ModelName == "" { + common.ApiErrorMsg(c, "模型名称不能为空") + return + } + + if err := m.Insert(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &m) +} + +// UpdateModelMeta 更新模型 +func UpdateModelMeta(c *gin.Context) { + statusOnly := c.Query("status_only") == "true" + + var m model.Model + if err := c.ShouldBindJSON(&m); err != nil { + common.ApiError(c, err) + return + } + if m.Id == 0 { + common.ApiErrorMsg(c, "缺少模型 ID") + return + } + + if statusOnly { + // 只更新状态,防止误清空其他字段 + if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil { + common.ApiError(c, err) + return + } + } else { + if err := m.Update(); err != nil { + common.ApiError(c, err) + return + } + } + common.ApiSuccess(c, &m) +} + +// DeleteModelMeta 删除模型 +func DeleteModelMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + if err := model.DB.Delete(&model.Model{}, id).Error; err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} + +// 辅助函数:填充 Endpoints 和 BoundChannels +func fillModelExtra(m *model.Model) { + if m.Endpoints == "" { + eps := model.GetModelSupportEndpointTypes(m.ModelName) + if b, err := json.Marshal(eps); err == nil { + m.Endpoints = string(b) + } + } + if channels, err := model.GetBoundChannels(m.ModelName); err == nil { + m.BoundChannels = channels + } + +} diff --git a/controller/vendor_meta.go b/controller/vendor_meta.go new file mode 100644 index 00000000..27e4294b --- /dev/null +++ b/controller/vendor_meta.go @@ -0,0 +1,114 @@ +package controller + +import ( + "strconv" + + "one-api/common" + "one-api/model" + + "github.com/gin-gonic/gin" +) + +// GetAllVendors 获取供应商列表(分页) +func GetAllVendors(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + var total int64 + model.DB.Model(&model.Vendor{}).Count(&total) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(vendors) + common.ApiSuccess(c, pageInfo) +} + +// SearchVendors 搜索供应商 +func SearchVendors(c *gin.Context) { + keyword := c.Query("keyword") + pageInfo := common.GetPageQuery(c) + vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(vendors) + common.ApiSuccess(c, pageInfo) +} + +// GetVendorMeta 根据 ID 获取供应商 +func GetVendorMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + v, err := model.GetVendorByID(id) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, v) +} + +// CreateVendorMeta 新建供应商 +func CreateVendorMeta(c *gin.Context) { + var v model.Vendor + if err := c.ShouldBindJSON(&v); err != nil { + common.ApiError(c, err) + return + } + if v.Name == "" { + common.ApiErrorMsg(c, "供应商名称不能为空") + return + } + if err := v.Insert(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &v) +} + +// UpdateVendorMeta 更新供应商 +func UpdateVendorMeta(c *gin.Context) { + var v model.Vendor + if err := c.ShouldBindJSON(&v); err != nil { + common.ApiError(c, err) + return + } + if v.Id == 0 { + common.ApiErrorMsg(c, "缺少供应商 ID") + return + } + // 检查名称冲突 + var dup int64 + _ = model.DB.Model(&model.Vendor{}).Where("name = ? AND id <> ?", v.Name, v.Id).Count(&dup).Error + if dup > 0 { + common.ApiErrorMsg(c, "供应商名称已存在") + return + } + + if err := v.Update(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &v) +} + +// DeleteVendorMeta 删除供应商 +func DeleteVendorMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} \ No newline at end of file diff --git a/model/main.go b/model/main.go index 013beacd..5be43703 100644 --- a/model/main.go +++ b/model/main.go @@ -250,6 +250,8 @@ func migrateDB() error { &TopUp{}, &QuotaData{}, &Task{}, + &Model{}, + &Vendor{}, &Setup{}, ) if err != nil { @@ -276,6 +278,8 @@ func migrateDBFast() error { {&TopUp{}, "TopUp"}, {&QuotaData{}, "QuotaData"}, {&Task{}, "Task"}, + {&Model{}, "Model"}, + {&Vendor{}, "Vendor"}, {&Setup{}, "Setup"}, } // 动态计算migration数量,确保errChan缓冲区足够大 diff --git a/model/model_meta.go b/model/model_meta.go new file mode 100644 index 00000000..f9b3dfc9 --- /dev/null +++ b/model/model_meta.go @@ -0,0 +1,108 @@ +package model + +import ( + "one-api/common" + + "gorm.io/gorm" +) + +// Model 用于存储模型的元数据,例如描述、标签等 +// ModelName 字段具有唯一性约束,确保每个模型只会出现一次 +// Tags 字段使用逗号分隔的字符串保存标签集合,后期可根据需要扩展为 JSON 类型 +// Status: 1 表示启用,0 表示禁用,保留以便后续功能扩展 +// CreatedTime 和 UpdatedTime 使用 Unix 时间戳(秒)保存方便跨数据库移植 +// DeletedAt 采用 GORM 的软删除特性,便于后续数据恢复 +// +// 该表设计遵循第三范式(3NF): +// 1. 每一列都与主键(Id 或 ModelName)直接相关 +// 2. 不存在部分依赖(ModelName 是唯一键) +// 3. 不存在传递依赖(描述、标签等都依赖于 ModelName,而非依赖于其他非主键列) +// 这样既保证了数据一致性,也方便后期扩展 + +type BoundChannel struct { + Name string `json:"name"` + Type int `json:"type"` +} + +type Model struct { + Id int `json:"id"` + ModelName string `json:"model_name" gorm:"uniqueIndex;size:128;not null"` + Description string `json:"description,omitempty" gorm:"type:text"` + Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` + VendorID int `json:"vendor_id,omitempty" gorm:"index"` + Endpoints string `json:"endpoints,omitempty" gorm:"type:text"` + Status int `json:"status" gorm:"default:1"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + + BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"` +} + +// Insert 创建新的模型元数据记录 +func (mi *Model) Insert() error { + now := common.GetTimestamp() + mi.CreatedTime = now + mi.UpdatedTime = now + return DB.Create(mi).Error +} + +// Update 更新现有模型记录 +func (mi *Model) Update() error { + mi.UpdatedTime = common.GetTimestamp() + return DB.Save(mi).Error +} + +// Delete 软删除模型记录 +func (mi *Model) Delete() error { + return DB.Delete(mi).Error +} + +// GetModelByName 根据模型名称查询元数据 +func GetModelByName(name string) (*Model, error) { + var mi Model + err := DB.Where("model_name = ?", name).First(&mi).Error + if err != nil { + return nil, err + } + return &mi, nil +} + +// GetAllModels 分页获取所有模型元数据 +func GetAllModels(offset int, limit int) ([]*Model, error) { + var models []*Model + err := DB.Offset(offset).Limit(limit).Find(&models).Error + return models, err +} + +// GetBoundChannels 查询支持该模型的渠道(名称+类型) +func GetBoundChannels(modelName string) ([]BoundChannel, error) { + var channels []BoundChannel + err := DB.Table("channels"). + Select("channels.name, channels.type"). + Joins("join abilities on abilities.channel_id = channels.id"). + Where("abilities.model = ? AND abilities.enabled = ?", modelName, true). + Group("channels.id"). + Scan(&channels).Error + return channels, err +} + +// SearchModels 根据关键词和供应商搜索模型,支持分页 +func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Model, int64, error) { + var models []*Model + db := DB.Model(&Model{}) + if keyword != "" { + like := "%" + keyword + "%" + db = db.Where("model_name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like) + } + if vendor != "" { + db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%") + } + var total int64 + err := db.Count(&total).Error + if err != nil { + return nil, 0, err + } + err = db.Offset(offset).Limit(limit).Order("id DESC").Find(&models).Error + return models, total, err +} diff --git a/model/vendor_meta.go b/model/vendor_meta.go new file mode 100644 index 00000000..1dcec351 --- /dev/null +++ b/model/vendor_meta.go @@ -0,0 +1,78 @@ +package model + +import ( + "one-api/common" + + "gorm.io/gorm" +) + +// Vendor 用于存储供应商信息,供模型引用 +// Name 唯一,用于在模型中关联 +// Icon 采用 @lobehub/icons 的图标名,前端可直接渲染 +// Status 预留字段,1 表示启用 +// 本表同样遵循 3NF 设计范式 + +type Vendor struct { + Id int `json:"id"` + Name string `json:"name" gorm:"uniqueIndex;size:128;not null"` + Description string `json:"description,omitempty" gorm:"type:text"` + Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` + Status int `json:"status" gorm:"default:1"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// Insert 创建新的供应商记录 +func (v *Vendor) Insert() error { + now := common.GetTimestamp() + v.CreatedTime = now + v.UpdatedTime = now + return DB.Create(v).Error +} + +// Update 更新供应商记录 +func (v *Vendor) Update() error { + v.UpdatedTime = common.GetTimestamp() + return DB.Save(v).Error +} + +// Delete 软删除供应商 +func (v *Vendor) Delete() error { + return DB.Delete(v).Error +} + +// GetVendorByID 根据 ID 获取供应商 +func GetVendorByID(id int) (*Vendor, error) { + var v Vendor + err := DB.First(&v, id).Error + if err != nil { + return nil, err + } + return &v, nil +} + +// GetAllVendors 获取全部供应商(分页) +func GetAllVendors(offset int, limit int) ([]*Vendor, error) { + var vendors []*Vendor + err := DB.Offset(offset).Limit(limit).Find(&vendors).Error + return vendors, err +} + +// SearchVendors 按关键字搜索供应商 +func SearchVendors(keyword string, offset int, limit int) ([]*Vendor, int64, error) { + db := DB.Model(&Vendor{}) + if keyword != "" { + like := "%" + keyword + "%" + db = db.Where("name LIKE ? OR description LIKE ?", like, like) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var vendors []*Vendor + if err := db.Offset(offset).Limit(limit).Order("id DESC").Find(&vendors).Error; err != nil { + return nil, 0, err + } + return vendors, total, nil +} \ No newline at end of file diff --git a/router/api-router.go b/router/api-router.go index bc49803a..e2b35be0 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -175,5 +175,27 @@ func SetApiRouter(router *gin.Engine) { taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask) taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask) } + + vendorRoute := apiRouter.Group("/vendors") + vendorRoute.Use(middleware.AdminAuth()) + { + vendorRoute.GET("/", controller.GetAllVendors) + vendorRoute.GET("/search", controller.SearchVendors) + vendorRoute.GET("/:id", controller.GetVendorMeta) + vendorRoute.POST("/", controller.CreateVendorMeta) + vendorRoute.PUT("/", controller.UpdateVendorMeta) + vendorRoute.DELETE("/:id", controller.DeleteVendorMeta) + } + + modelsRoute := apiRouter.Group("/models") + modelsRoute.Use(middleware.AdminAuth()) + { + modelsRoute.GET("/", controller.GetAllModelsMeta) + modelsRoute.GET("/search", controller.SearchModelsMeta) + modelsRoute.GET("/:id", controller.GetModelMeta) + modelsRoute.POST("/", controller.CreateModelMeta) + modelsRoute.PUT("/", controller.UpdateModelMeta) + modelsRoute.DELETE("/:id", controller.DeleteModelMeta) + } } } diff --git a/web/src/App.js b/web/src/App.js index 47304b16..bf8397ba 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -39,6 +39,7 @@ import Chat2Link from './pages/Chat2Link'; import Midjourney from './pages/Midjourney'; import Pricing from './pages/Pricing/index.js'; import Task from './pages/Task/index.js'; +import ModelPage from './pages/Model/index.js'; import Playground from './pages/Playground/index.js'; import OAuth2Callback from './components/auth/OAuth2Callback.js'; import PersonalSetting from './components/settings/PersonalSetting.js'; @@ -71,6 +72,14 @@ function App() { } /> + + + + } + /> { } }) => { const adminItems = useMemo( () => [ + { + text: t('模型管理'), + itemKey: 'models', + to: '/console/models', + className: isAdmin() ? '' : 'tableHiddle', + }, { text: t('渠道管理'), itemKey: 'channel', diff --git a/web/src/components/table/models/ModelsActions.jsx b/web/src/components/table/models/ModelsActions.jsx new file mode 100644 index 00000000..78d3d5b0 --- /dev/null +++ b/web/src/components/table/models/ModelsActions.jsx @@ -0,0 +1,100 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState } from 'react'; +import { Button, Space, Modal } from '@douyinfe/semi-ui'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; +import { showError } from '../../../helpers'; + +const ModelsActions = ({ + selectedKeys, + setEditingModel, + setShowEdit, + batchDeleteModels, + compactMode, + setCompactMode, + t, +}) => { + // Modal states + const [showDeleteModal, setShowDeleteModal] = useState(false); + + // Handle delete selected models with confirmation + const handleDeleteSelectedModels = () => { + if (selectedKeys.length === 0) { + showError(t('请至少选择一个模型!')); + return; + } + setShowDeleteModal(true); + }; + + // Handle delete confirmation + const handleConfirmDelete = () => { + batchDeleteModels(); + setShowDeleteModal(false); + }; + + return ( + <> +
+ + + + + +
+ + setShowDeleteModal(false)} + onOk={handleConfirmDelete} + type="warning" + > +
+ {t('确定要删除所选的 {{count}} 个模型吗?', { count: selectedKeys.length })} +
+
+ + ); +}; + +export default ModelsActions; \ No newline at end of file diff --git a/web/src/components/table/models/ModelsColumnDefs.js b/web/src/components/table/models/ModelsColumnDefs.js new file mode 100644 index 00000000..ef404958 --- /dev/null +++ b/web/src/components/table/models/ModelsColumnDefs.js @@ -0,0 +1,259 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { + Button, + Space, + Tag, + Typography, + Modal, + Popover +} from '@douyinfe/semi-ui'; +import { + timestamp2string, + getLobeHubIcon, + stringToColor +} from '../../../helpers'; + +const { Text } = Typography; + +// Render timestamp +function renderTimestamp(timestamp) { + return <>{timestamp2string(timestamp)}; +} + +// Render vendor column with icon +const renderVendorTag = (vendorId, vendorMap, t) => { + if (!vendorId || !vendorMap[vendorId]) return '-'; + const v = vendorMap[vendorId]; + return ( + + {v.name} + + ); +}; + +// Render description with ellipsis +const renderDescription = (text) => { + return ( + + {text || '-'} + + ); +}; + +// Render tags +const renderTags = (text) => { + if (!text) return '-'; + const tagsArr = text.split(',').filter(Boolean); + const maxDisplayTags = 3; + const displayTags = tagsArr.slice(0, maxDisplayTags); + const remainingTags = tagsArr.slice(maxDisplayTags); + + return ( + + {displayTags.map((tag, index) => ( + + {tag} + + ))} + {remainingTags.length > 0 && ( + + + {remainingTags.map((tag, index) => ( + + {tag} + + ))} + + + } + position="top" + > + + +{remainingTags.length} + + + )} + + ); +}; + +// Render endpoints +const renderEndpoints = (text) => { + try { + const arr = JSON.parse(text); + if (Array.isArray(arr)) { + return ( + + {arr.map((ep) => ( + + {ep} + + ))} + + ); + } + } catch (_) { } + return text || '-'; +}; + +// Render bound channels +const renderBoundChannels = (channels) => { + if (!channels || channels.length === 0) return '-'; + return ( + + {channels.map((c, idx) => ( + + {c.name}({c.type}) + + ))} + + ); +}; + +// Render operations column +const renderOperations = (text, record, setEditingModel, setShowEdit, manageModel, refresh, t) => { + return ( + + {record.status === 1 ? ( + + ) : ( + + )} + + + + + + ); +}; + +export const getModelsColumns = ({ + t, + manageModel, + setEditingModel, + setShowEdit, + refresh, + vendorMap, +}) => { + return [ + { + title: t('模型名称'), + dataIndex: 'model_name', + }, + { + title: t('描述'), + dataIndex: 'description', + render: renderDescription, + }, + { + title: t('供应商'), + dataIndex: 'vendor_id', + render: (vendorId, record) => renderVendorTag(vendorId, vendorMap, t), + }, + { + title: t('标签'), + dataIndex: 'tags', + render: renderTags, + }, + { + title: t('端点'), + dataIndex: 'endpoints', + render: renderEndpoints, + }, + { + title: t('已绑定渠道'), + dataIndex: 'bound_channels', + render: renderBoundChannels, + }, + { + title: t('创建时间'), + dataIndex: 'created_time', + render: (text, record, index) => { + return
{renderTimestamp(text)}
; + }, + }, + { + title: t('更新时间'), + dataIndex: 'updated_time', + render: (text, record, index) => { + return
{renderTimestamp(text)}
; + }, + }, + { + title: '', + dataIndex: 'operate', + fixed: 'right', + render: (text, record, index) => renderOperations( + text, + record, + setEditingModel, + setShowEdit, + manageModel, + refresh, + t + ), + }, + ]; +}; \ No newline at end of file diff --git a/web/src/components/table/models/ModelsDescription.jsx b/web/src/components/table/models/ModelsDescription.jsx new file mode 100644 index 00000000..5fc3f1f7 --- /dev/null +++ b/web/src/components/table/models/ModelsDescription.jsx @@ -0,0 +1,44 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Typography } from '@douyinfe/semi-ui'; +import { Layers } from 'lucide-react'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; + +const { Text } = Typography; + +const ModelsDescription = ({ compactMode, setCompactMode, t }) => { + return ( +
+
+ + {t('模型管理')} +
+ + +
+ ); +}; + +export default ModelsDescription; \ No newline at end of file diff --git a/web/src/components/table/models/ModelsFilters.jsx b/web/src/components/table/models/ModelsFilters.jsx new file mode 100644 index 00000000..0bccb835 --- /dev/null +++ b/web/src/components/table/models/ModelsFilters.jsx @@ -0,0 +1,106 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useRef } from 'react'; +import { Form, Button } from '@douyinfe/semi-ui'; +import { IconSearch } from '@douyinfe/semi-icons'; + +const ModelsFilters = ({ + formInitValues, + setFormApi, + searchModels, + loading, + searching, + t, +}) => { + // Handle form reset and immediate search + const formApiRef = useRef(null); + + const handleReset = () => { + if (!formApiRef.current) return; + formApiRef.current.reset(); + setTimeout(() => { + searchModels(); + }, 100); + }; + + return ( +
{ + setFormApi(api); + formApiRef.current = api; + }} + onSubmit={searchModels} + allowEmpty={true} + autoComplete="off" + layout="horizontal" + trigger="change" + stopValidateWithError={false} + className="w-full md:w-auto order-1 md:order-2" + > +
+
+ } + placeholder={t('搜索模型名称')} + showClear + pure + size="small" + /> +
+ +
+ } + placeholder={t('搜索供应商')} + showClear + pure + size="small" + /> +
+ +
+ + + +
+
+
+ ); +}; + +export default ModelsFilters; \ No newline at end of file diff --git a/web/src/components/table/models/ModelsTable.jsx b/web/src/components/table/models/ModelsTable.jsx new file mode 100644 index 00000000..7ced70c5 --- /dev/null +++ b/web/src/components/table/models/ModelsTable.jsx @@ -0,0 +1,110 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useMemo } from 'react'; +import { Empty } from '@douyinfe/semi-ui'; +import CardTable from '../../common/ui/CardTable.js'; +import { + IllustrationNoResult, + IllustrationNoResultDark, +} from '@douyinfe/semi-illustrations'; +import { getModelsColumns } from './ModelsColumnDefs.js'; + +const ModelsTable = (modelsData) => { + const { + models, + loading, + activePage, + pageSize, + modelCount, + compactMode, + handlePageChange, + handlePageSizeChange, + rowSelection, + handleRow, + manageModel, + setEditingModel, + setShowEdit, + refresh, + vendorMap, + t, + } = modelsData; + + // Get all columns + const columns = useMemo(() => { + return getModelsColumns({ + t, + manageModel, + setEditingModel, + setShowEdit, + refresh, + vendorMap, + }); + }, [ + t, + manageModel, + setEditingModel, + setShowEdit, + refresh, + ]); + + // Handle compact mode by removing fixed positioning + const tableColumns = useMemo(() => { + return compactMode ? columns.map(col => { + if (col.dataIndex === 'operate') { + const { fixed, ...rest } = col; + return rest; + } + return col; + }) : columns; + }, [compactMode, columns]); + + return ( + } + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } + className="rounded-xl overflow-hidden" + size="middle" + /> + ); +}; + +export default ModelsTable; \ No newline at end of file diff --git a/web/src/components/table/models/ModelsTabs.jsx b/web/src/components/table/models/ModelsTabs.jsx new file mode 100644 index 00000000..09dab91f --- /dev/null +++ b/web/src/components/table/models/ModelsTabs.jsx @@ -0,0 +1,169 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Tabs, TabPane, Tag, Button, Dropdown, Modal } from '@douyinfe/semi-ui'; +import { IconEdit, IconDelete } from '@douyinfe/semi-icons'; +import { getLobeHubIcon, showError, showSuccess } from '../../../helpers'; +import { API } from '../../../helpers'; + +const ModelsTabs = ({ + activeVendorKey, + setActiveVendorKey, + vendorCounts, + vendors, + loadModels, + activePage, + pageSize, + setActivePage, + setShowAddVendor, + setShowEditVendor, + setEditingVendor, + loadVendors, + t +}) => { + const handleTabChange = (key) => { + setActiveVendorKey(key); + setActivePage(1); + loadModels(1, pageSize, key); + }; + + const handleEditVendor = (vendor, e) => { + e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换 + setEditingVendor(vendor); + setShowEditVendor(true); + }; + + const handleDeleteVendor = async (vendor, e) => { + e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换 + try { + const res = await API.delete(`/api/vendors/${vendor.id}`); + if (res.data.success) { + showSuccess(t('供应商删除成功')); + // 如果删除的是当前选中的供应商,切换到"全部" + if (activeVendorKey === String(vendor.id)) { + setActiveVendorKey('all'); + loadModels(1, pageSize, 'all'); + } else { + loadModels(activePage, pageSize, activeVendorKey); + } + loadVendors(); // 重新加载供应商列表 + } else { + showError(res.data.message || t('删除失败')); + } + } catch (error) { + showError(error.response?.data?.message || t('删除失败')); + } + }; + + return ( + setShowAddVendor(true)} + > + {t('新增供应商')} + + } + > + + {t('全部')} + + {vendorCounts['all'] || 0} + + + } + /> + + {vendors.map((vendor) => { + const key = String(vendor.id); + const count = vendorCounts[vendor.id] || 0; + return ( + + {getLobeHubIcon(vendor.icon || 'Layers', 14)} + {vendor.name} + + {count} + + + } + onClick={(e) => handleEditVendor(vendor, e)} + > + {t('编辑')} + + } + onClick={(e) => { + e.stopPropagation(); + Modal.confirm({ + title: t('确认删除'), + content: t('确定要删除供应商 "{{name}}" 吗?此操作不可撤销。', { name: vendor.name }), + onOk: () => handleDeleteVendor(vendor, e), + okText: t('删除'), + cancelText: t('取消'), + type: 'warning', + okType: 'danger', + }); + }} + > + {t('删除')} + + + } + onClickOutSide={(e) => e.stopPropagation()} + > + + + + } + /> + ); + })} + + ); +}; + +export default ModelsTabs; \ No newline at end of file diff --git a/web/src/components/table/models/index.jsx b/web/src/components/table/models/index.jsx new file mode 100644 index 00000000..4732e83d --- /dev/null +++ b/web/src/components/table/models/index.jsx @@ -0,0 +1,140 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import CardPro from '../../common/ui/CardPro'; +import ModelsTable from './ModelsTable.jsx'; +import ModelsActions from './ModelsActions.jsx'; +import ModelsFilters from './ModelsFilters.jsx'; +import ModelsTabs from './ModelsTabs.jsx'; +import EditModelModal from './modals/EditModelModal.jsx'; +import EditVendorModal from './modals/EditVendorModal.jsx'; +import { useModelsData } from '../../../hooks/models/useModelsData'; +import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { createCardProPagination } from '../../../helpers/utils'; + +const ModelsPage = () => { + const modelsData = useModelsData(); + const isMobile = useIsMobile(); + + const { + // Edit state + showEdit, + editingModel, + closeEdit, + refresh, + + // Actions state + selectedKeys, + setEditingModel, + setShowEdit, + batchDeleteModels, + + // Filters state + formInitValues, + setFormApi, + searchModels, + loading, + searching, + + // Description state + compactMode, + setCompactMode, + + // Vendor state + showAddVendor, + setShowAddVendor, + showEditVendor, + setShowEditVendor, + editingVendor, + setEditingVendor, + loadVendors, + + // Translation + t, + } = modelsData; + + return ( + <> + + + { + setShowAddVendor(false); + setShowEditVendor(false); + setEditingVendor({ id: undefined }); + }} + editingVendor={showEditVendor ? editingVendor : { id: undefined }} + refresh={() => { + loadVendors(); + refresh(); + }} + /> + + } + actionsArea={ +
+ + +
+ +
+
+ } + paginationArea={createCardProPagination({ + currentPage: modelsData.activePage, + pageSize: modelsData.pageSize, + total: modelsData.modelCount, + onPageChange: modelsData.handlePageChange, + onPageSizeChange: modelsData.handlePageSizeChange, + isMobile: isMobile, + t: modelsData.t, + })} + t={modelsData.t} + > + +
+ + ); +}; + +export default ModelsPage; diff --git a/web/src/components/table/models/modals/EditModelModal.jsx b/web/src/components/table/models/modals/EditModelModal.jsx new file mode 100644 index 00000000..70015cca --- /dev/null +++ b/web/src/components/table/models/modals/EditModelModal.jsx @@ -0,0 +1,368 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { + SideSheet, + Form, + Button, + Space, + Spin, + Typography, + Card, + Tag, + Avatar, + Col, + Row, +} from '@douyinfe/semi-ui'; +import { + IconSave, + IconClose, + IconLayers, +} from '@douyinfe/semi-icons'; +import { API, showError, showSuccess } from '../../../../helpers'; +import { useTranslation } from 'react-i18next'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile'; + +const endpointOptions = [ + { label: 'OpenAI', value: 'openai' }, + { label: 'Anthropic', value: 'anthropic' }, + { label: 'Gemini', value: 'gemini' }, + { label: 'Image Generation', value: 'image-generation' }, + { label: 'Jina Rerank', value: 'jina-rerank' }, +]; + +const { Text, Title } = Typography; + +const EditModelModal = (props) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const isMobile = useIsMobile(); + const formApiRef = useRef(null); + const isEdit = props.editingModel && props.editingModel.id !== undefined; + const placement = useMemo(() => (isEdit ? 'right' : 'left'), [isEdit]); + + // 供应商列表 + const [vendors, setVendors] = useState([]); + + // 获取供应商列表 + const fetchVendors = async () => { + try { + const res = await API.get('/api/vendors/?page_size=1000'); // 获取全部供应商 + if (res.data.success) { + const items = res.data.data.items || res.data.data || []; + setVendors(Array.isArray(items) ? items : []); + } + } catch (error) { + // ignore + } + }; + + useEffect(() => { + fetchVendors(); + }, []); + + + const getInitValues = () => ({ + model_name: '', + description: '', + tags: [], + vendor_id: undefined, + vendor: '', + vendor_icon: '', + endpoints: [], + status: true, + }); + + const handleCancel = () => { + props.handleClose(); + }; + + const loadModel = async () => { + if (!isEdit || !props.editingModel.id) return; + + setLoading(true); + try { + const res = await API.get(`/api/models/${props.editingModel.id}`); + const { success, message, data } = res.data; + if (success) { + // 处理tags + if (data.tags) { + data.tags = data.tags.split(',').filter(Boolean); + } else { + data.tags = []; + } + // 处理endpoints + if (data.endpoints) { + try { + data.endpoints = JSON.parse(data.endpoints); + } catch (e) { + data.endpoints = []; + } + } else { + data.endpoints = []; + } + // 处理status,将数字转为布尔值 + data.status = data.status === 1; + if (formApiRef.current) { + formApiRef.current.setValues({ ...getInitValues(), ...data }); + } + } else { + showError(message); + } + } catch (error) { + showError(t('加载模型信息失败')); + } + setLoading(false); + }; + + useEffect(() => { + if (formApiRef.current) { + if (!isEdit) { + formApiRef.current.setValues(getInitValues()); + } + } + }, [props.editingModel?.id]); + + useEffect(() => { + if (props.visiable) { + if (isEdit) { + loadModel(); + } else { + formApiRef.current?.setValues(getInitValues()); + } + } else { + formApiRef.current?.reset(); + } + }, [props.visiable, props.editingModel?.id]); + + const submit = async (values) => { + setLoading(true); + try { + const submitData = { + ...values, + tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags, + endpoints: JSON.stringify(values.endpoints || []), + status: values.status ? 1 : 0, + }; + + if (isEdit) { + submitData.id = props.editingModel.id; + const res = await API.put('/api/models/', submitData); + const { success, message } = res.data; + if (success) { + showSuccess(t('模型更新成功!')); + props.refresh(); + props.handleClose(); + } else { + showError(t(message)); + } + } else { + const res = await API.post('/api/models/', submitData); + const { success, message } = res.data; + if (success) { + showSuccess(t('模型创建成功!')); + props.refresh(); + props.handleClose(); + } else { + showError(t(message)); + } + } + } catch (error) { + showError(error.response?.data?.message || t('操作失败')); + } + setLoading(false); + formApiRef.current?.setValues(getInitValues()); + }; + + return ( + + {isEdit ? ( + + {t('更新')} + + ) : ( + + {t('新建')} + + )} + + {isEdit ? t('更新模型信息') : t('创建新的模型')} + + + } + bodyStyle={{ padding: '0' }} + visible={props.visiable} + width={isMobile ? '100%' : 600} + footer={ +
+ + + + +
+ } + closeIcon={null} + onCancel={() => handleCancel()} + > + +
(formApiRef.current = api)} + onSubmit={submit} + > + {({ values }) => ( +
+ {/* 基本信息 */} + +
+ + + +
+ {t('基本信息')} +
{t('设置模型的基本信息')}
+
+
+ + + + + + + + + + + +
+ + {/* 供应商信息 */} + +
+ + + +
+ {t('供应商信息')} +
{t('设置模型的供应商相关信息')}
+
+
+ + + ({ label: v.name, value: v.id }))} + filter + showClear + style={{ width: '100%' }} + onChange={(value) => { + const vendorInfo = vendors.find(v => v.id === value); + if (vendorInfo && formApiRef.current) { + formApiRef.current.setValue('vendor', vendorInfo.name); + } + }} + /> + + +
+ + {/* 功能配置 */} + +
+ + + +
+ {t('功能配置')} +
{t('设置模型的功能和状态')}
+
+
+ + + + + + + + +
+
+ )} +
+
+
+ ); +}; + +export default EditModelModal; \ No newline at end of file diff --git a/web/src/components/table/models/modals/EditVendorModal.jsx b/web/src/components/table/models/modals/EditVendorModal.jsx new file mode 100644 index 00000000..9ddf5cb4 --- /dev/null +++ b/web/src/components/table/models/modals/EditVendorModal.jsx @@ -0,0 +1,177 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useRef, useEffect } from 'react'; +import { + Modal, + Form, + Col, + Row, +} from '@douyinfe/semi-ui'; +import { API, showError, showSuccess } from '../../../../helpers'; +import { useTranslation } from 'react-i18next'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile'; + +const EditVendorModal = ({ visible, handleClose, refresh, editingVendor }) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const formApiRef = useRef(null); + + const isMobile = useIsMobile(); + const isEdit = editingVendor && editingVendor.id !== undefined; + + const getInitValues = () => ({ + name: '', + description: '', + icon: '', + status: true, + }); + + const handleCancel = () => { + handleClose(); + formApiRef.current?.reset(); + }; + + const loadVendor = async () => { + if (!isEdit || !editingVendor.id) return; + + setLoading(true); + try { + const res = await API.get(`/api/vendors/${editingVendor.id}`); + const { success, message, data } = res.data; + if (success) { + // 将数字状态转为布尔值 + data.status = data.status === 1; + if (formApiRef.current) { + formApiRef.current.setValues({ ...getInitValues(), ...data }); + } + } else { + showError(message); + } + } catch (error) { + showError(t('加载供应商信息失败')); + } + setLoading(false); + }; + + useEffect(() => { + if (visible) { + if (isEdit) { + loadVendor(); + } else { + formApiRef.current?.setValues(getInitValues()); + } + } else { + formApiRef.current?.reset(); + } + }, [visible, editingVendor?.id]); + + const submit = async (values) => { + setLoading(true); + try { + // 转换 status 为数字 + const submitData = { + ...values, + status: values.status ? 1 : 0, + }; + + if (isEdit) { + submitData.id = editingVendor.id; + const res = await API.put('/api/vendors/', submitData); + const { success, message } = res.data; + if (success) { + showSuccess(t('供应商更新成功!')); + refresh(); + handleClose(); + } else { + showError(t(message)); + } + } else { + const res = await API.post('/api/vendors/', submitData); + const { success, message } = res.data; + if (success) { + showSuccess(t('供应商创建成功!')); + refresh(); + handleClose(); + } else { + showError(t(message)); + } + } + } catch (error) { + showError(error.response?.data?.message || t('操作失败')); + } + setLoading(false); + }; + + return ( + formApiRef.current?.submitForm()} + onCancel={handleCancel} + confirmLoading={loading} + size={isMobile ? 'full-width' : 'small'} + > +
(formApiRef.current = api)} + onSubmit={submit} + > + + + + + + + + + + + + + + +
+
+ ); +}; + +export default EditVendorModal; \ No newline at end of file diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index 1178d5f9..8371c9ba 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -18,10 +18,11 @@ For commercial licensing, please contact support@quantumnous.com */ import i18next from 'i18next'; -import { Modal, Tag, Typography } from '@douyinfe/semi-ui'; +import { Modal, Tag, Typography, Avatar } from '@douyinfe/semi-ui'; import { copy, showSuccess } from './utils'; import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js'; import { visit } from 'unist-util-visit'; +import * as LobeIcons from '@lobehub/icons'; import { OpenAI, Claude, @@ -85,6 +86,7 @@ export const sidebarIconColors = { gift: '#F43F5E', // 玫红色 user: '#10B981', // 绿色 settings: '#F97316', // 橙色 + models: '#10B981', // 绿色 }; // 获取侧边栏Lucide图标组件 @@ -177,6 +179,13 @@ export function getLucideIcon(key, selected = false) { color={selected ? sidebarIconColors.user : 'currentColor'} /> ); + case 'models': + return ( + + ); case 'setting': return ( ?; + } + + let IconComponent; + + if (iconName.includes('.')) { + const [base, variant] = iconName.split('.'); + const BaseIcon = LobeIcons[base]; + IconComponent = BaseIcon ? BaseIcon[variant] : undefined; + } else { + IconComponent = LobeIcons[iconName]; + } + + if (IconComponent && (typeof IconComponent === 'function' || typeof IconComponent === 'object')) { + return ; + } + + const firstLetter = iconName.charAt(0).toUpperCase(); + return {firstLetter}; +} + // 颜色列表 const colors = [ 'amber', @@ -891,13 +931,13 @@ export function renderQuota(quota, digits = 2) { if (displayInCurrency) { const result = quota / quotaPerUnit; const fixedResult = result.toFixed(digits); - + // 如果 toFixed 后结果为 0 但原始值不为 0,显示最小值 if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) { const minValue = Math.pow(10, -digits); return '$' + minValue.toFixed(digits); } - + return '$' + fixedResult; } return renderNumber(quota); diff --git a/web/src/hooks/models/useModelsData.js b/web/src/hooks/models/useModelsData.js new file mode 100644 index 00000000..fe83168e --- /dev/null +++ b/web/src/hooks/models/useModelsData.js @@ -0,0 +1,378 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import { useState, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { API, showError, showSuccess } from '../../helpers'; +import { ITEMS_PER_PAGE } from '../../constants'; +import { useTableCompactMode } from '../common/useTableCompactMode'; + +export const useModelsData = () => { + const { t } = useTranslation(); + const [compactMode, setCompactMode] = useTableCompactMode('models'); + + // State management + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + const [activePage, setActivePage] = useState(1); + const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); + const [searching, setSearching] = useState(false); + const [modelCount, setModelCount] = useState(0); + + // Modal states + const [showEdit, setShowEdit] = useState(false); + const [editingModel, setEditingModel] = useState({ + id: undefined, + }); + + // Row selection + const [selectedKeys, setSelectedKeys] = useState([]); + const rowSelection = { + getCheckboxProps: (record) => ({ + name: record.model_name, + }), + selectedRowKeys: selectedKeys.map((model) => model.id), + onChange: (selectedRowKeys, selectedRows) => { + setSelectedKeys(selectedRows); + }, + }; + + // Form initial values + const formInitValues = { + searchKeyword: '', + searchVendor: '', + }; + + // Form API reference + const [formApi, setFormApi] = useState(null); + + // Get form values helper function + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + return { + searchKeyword: formValues.searchKeyword || '', + searchVendor: formValues.searchVendor || '', + }; + }; + + // Close edit modal + const closeEdit = () => { + setShowEdit(false); + setTimeout(() => { + setEditingModel({ id: undefined }); + }, 500); + }; + + // Set model format with key field + const setModelFormat = (models) => { + for (let i = 0; i < models.length; i++) { + models[i].key = models[i].id; + } + setModels(models); + }; + + // 获取供应商列表 + const [vendors, setVendors] = useState([]); + const [vendorCounts, setVendorCounts] = useState({}); + const [activeVendorKey, setActiveVendorKey] = useState('all'); + const [showAddVendor, setShowAddVendor] = useState(false); + const [showEditVendor, setShowEditVendor] = useState(false); + const [editingVendor, setEditingVendor] = useState({ id: undefined }); + + const vendorMap = useMemo(() => { + const map = {}; + vendors.forEach(v => { + map[v.id] = v; + }); + return map; + }, [vendors]); + + // 加载供应商列表 + const loadVendors = async () => { + try { + const res = await API.get('/api/vendors/?page_size=1000'); + if (res.data.success) { + const items = res.data.data.items || res.data.data || []; + setVendors(Array.isArray(items) ? items : []); + } + } catch (_) { + // ignore + } + }; + + // Load models data + const loadModels = async (page = 1, size = pageSize, vendorKey = activeVendorKey) => { + setLoading(true); + try { + let url = `/api/models/?p=${page}&page_size=${size}`; + if (vendorKey && vendorKey !== 'all') { + // 按供应商筛选,通过vendor搜索接口 + const vendor = vendors.find(v => String(v.id) === vendorKey); + if (vendor) { + url = `/api/models/search?vendor=${vendor.name}&p=${page}&page_size=${size}`; + } + } + + const res = await API.get(url); + const { success, message, data } = res.data; + if (success) { + const items = data.items || data || []; + const newPageData = Array.isArray(items) ? items : []; + setActivePage(data.page || page); + setModelCount(data.total || newPageData.length); + setModelFormat(newPageData); + + // 更新供应商统计 + updateVendorCounts(newPageData); + } else { + showError(message); + setModels([]); + } + } catch (error) { + console.error(error); + showError(t('获取模型列表失败')); + setModels([]); + } + setLoading(false); + }; + + // Refresh data + const refresh = async (page = activePage) => { + await loadModels(page, pageSize); + }; + + // Search models with keyword and vendor + const searchModels = async () => { + const formValues = getFormValues(); + const { searchKeyword, searchVendor } = formValues; + + if (searchKeyword === '' && searchVendor === '') { + // If keyword is blank, load models instead + await loadModels(1, pageSize); + return; + } + + setSearching(true); + try { + const res = await API.get( + `/api/models/search?keyword=${searchKeyword}&vendor=${searchVendor}&p=1&page_size=${pageSize}`, + ); + const { success, message, data } = res.data; + if (success) { + const items = data.items || data || []; + const newPageData = Array.isArray(items) ? items : []; + setActivePage(data.page || 1); + setModelCount(data.total || newPageData.length); + setModelFormat(newPageData); + } else { + showError(message); + setModels([]); + } + } catch (error) { + console.error(error); + showError(t('搜索模型失败')); + setModels([]); + } + setSearching(false); + }; + + // Manage model (enable/disable/delete) + const manageModel = async (id, action, record) => { + let res; + switch (action) { + case 'delete': + res = await API.delete(`/api/models/${id}`); + break; + case 'enable': + res = await API.put('/api/models/?status_only=true', { id, status: 1 }); + break; + case 'disable': + res = await API.put('/api/models/?status_only=true', { id, status: 0 }); + break; + default: + return; + } + + const { success, message } = res.data; + if (success) { + showSuccess(t('操作成功完成!')); + if (action === 'delete') { + await refresh(); + } else { + // Update local state for enable/disable + setModels(prevModels => + prevModels.map(model => + model.id === id ? { ...model, status: action === 'enable' ? 1 : 0 } : model + ) + ); + } + } else { + showError(message); + } + }; + + // 更新供应商统计 + const updateVendorCounts = (models) => { + const counts = { all: models.length }; + models.forEach(model => { + if (model.vendor_id) { + counts[model.vendor_id] = (counts[model.vendor_id] || 0) + 1; + } + }); + setVendorCounts(counts); + }; + + // Handle page change + const handlePageChange = (page) => { + setActivePage(page); + loadModels(page, pageSize, activeVendorKey); + }; + + // Handle page size change + const handlePageSizeChange = async (size) => { + setPageSize(size); + setActivePage(1); + await loadModels(1, size, activeVendorKey); + }; + + // Handle row click + const handleRow = (record, index) => { + return { + onClick: (event) => { + // Don't trigger row selection when clicking on buttons + if (event.target.closest('button, .semi-button')) { + return; + } + const newSelectedKeys = selectedKeys.some(item => item.id === record.id) + ? selectedKeys.filter(item => item.id !== record.id) + : [...selectedKeys, record]; + setSelectedKeys(newSelectedKeys); + }, + }; + }; + + // Batch delete models + const batchDeleteModels = async () => { + if (selectedKeys.length === 0) { + showError(t('请至少选择一个模型')); + return; + } + + try { + const deletePromises = selectedKeys.map(model => + API.delete(`/api/models/${model.id}`) + ); + + const results = await Promise.all(deletePromises); + let successCount = 0; + + results.forEach((res, index) => { + if (res.data.success) { + successCount++; + } else { + showError(`删除模型 ${selectedKeys[index].model_name} 失败: ${res.data.message}`); + } + }); + + if (successCount > 0) { + showSuccess(t(`成功删除 ${successCount} 个模型`)); + setSelectedKeys([]); + await refresh(); + } + } catch (error) { + showError(t('批量删除失败')); + } + }; + + // Copy text helper + const copyText = async (text) => { + try { + await navigator.clipboard.writeText(text); + showSuccess(t('复制成功')); + } catch (error) { + console.error('Copy failed:', error); + showError(t('复制失败')); + } + }; + + // Initial load + useEffect(() => { + loadVendors(); + loadModels(); + }, []); + + return { + // Data state + models, + loading, + searching, + activePage, + pageSize, + modelCount, + + // Selection state + selectedKeys, + rowSelection, + handleRow, + + // Modal state + showEdit, + editingModel, + setEditingModel, + setShowEdit, + closeEdit, + + // Form state + formInitValues, + setFormApi, + + // Actions + loadModels, + searchModels, + refresh, + manageModel, + batchDeleteModels, + copyText, + + // Pagination + handlePageChange, + handlePageSizeChange, + + // UI state + compactMode, + setCompactMode, + + // Vendor data + vendors, + vendorMap, + vendorCounts, + activeVendorKey, + setActiveVendorKey, + showAddVendor, + setShowAddVendor, + showEditVendor, + setShowEditVendor, + editingVendor, + setEditingVendor, + loadVendors, + + // Translation + t, + }; +}; \ No newline at end of file diff --git a/web/src/index.css b/web/src/index.css index b624d749..98d96679 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -53,6 +53,7 @@ code { /* ==================== 导航和侧边栏样式 ==================== */ /* 导航项样式 */ +.semi-tagInput, .semi-input-textarea-wrapper, .semi-navigation-sub-title, .semi-chat-inputBox-sendButton, diff --git a/web/src/pages/Model/index.js b/web/src/pages/Model/index.js new file mode 100644 index 00000000..7d9d1c9f --- /dev/null +++ b/web/src/pages/Model/index.js @@ -0,0 +1,12 @@ +import React from 'react'; +import ModelsTable from '../../components/table/models'; + +const ModelPage = () => { + return ( +
+ +
+ ); +}; + +export default ModelPage;