feat: add model configuration management and logging enhancements
- Introduced a new ModelConfigEditor component for managing model configurations. - Added API endpoints for model configuration management, including fetching, saving, and importing/exporting configurations. - Enhanced logging for request bodies, particularly for multimodal requests containing image URLs. - Updated the .gitignore to include new configuration files. - Refactored model handling in the controller to support custom model configurations.
This commit is contained in:
parent
8b87c3d404
commit
a9a224e7dd
4
.gitignore
vendored
4
.gitignore
vendored
@ -13,4 +13,6 @@ cmd.md
|
||||
/one-api
|
||||
temp
|
||||
.DS_Store
|
||||
.claude
|
||||
.claude
|
||||
.claw/
|
||||
.claw-todos.json
|
||||
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/songquanpeng/one-api/common/ctxkey"
|
||||
"github.com/songquanpeng/one-api/common/logger"
|
||||
)
|
||||
|
||||
func GetRequestBody(c *gin.Context) ([]byte, error) {
|
||||
@ -29,6 +30,18 @@ func UnmarshalBodyReusable(c *gin.Context, v any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 添加诊断日志:检查原始请求体是否包含image_url
|
||||
bodyStr := string(requestBody)
|
||||
if strings.Contains(bodyStr, "image_url") {
|
||||
logger.Infof(c.Request.Context(), "=== RAW REQUEST BODY contains image_url ===")
|
||||
if len(bodyStr) < 500 {
|
||||
logger.Infof(c.Request.Context(), "=== RAW BODY: %s ===", bodyStr)
|
||||
} else {
|
||||
logger.Infof(c.Request.Context(), "=== RAW BODY (first 500 chars): %s ===", bodyStr[:500])
|
||||
}
|
||||
}
|
||||
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "application/json") {
|
||||
err = json.Unmarshal(requestBody, &v)
|
||||
|
||||
@ -11,12 +11,14 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/songquanpeng/one-api/common/config"
|
||||
"github.com/songquanpeng/one-api/common/ctxkey"
|
||||
"github.com/songquanpeng/one-api/common/logger"
|
||||
"github.com/songquanpeng/one-api/model"
|
||||
billingratio "github.com/songquanpeng/one-api/relay/billing/ratio"
|
||||
relay "github.com/songquanpeng/one-api/relay"
|
||||
"github.com/songquanpeng/one-api/relay/adaptor/openai"
|
||||
"github.com/songquanpeng/one-api/relay/apitype"
|
||||
billingratio "github.com/songquanpeng/one-api/relay/billing/ratio"
|
||||
"github.com/songquanpeng/one-api/relay/channeltype"
|
||||
"github.com/songquanpeng/one-api/relay/meta"
|
||||
relaymodel "github.com/songquanpeng/one-api/relay/model"
|
||||
@ -234,11 +236,26 @@ type ModelCatalogItem struct {
|
||||
InputPrice float64 `json:"input_price"`
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
// PricingDiscount: e.g. "0.8" means 20% off; applied to both input and output prices
|
||||
PricingDiscount string `json:"pricing_discount"`
|
||||
Tags []string `json:"tags"`
|
||||
PricingDiscount string `json:"pricing_discount"`
|
||||
Tags []string `json:"tags"`
|
||||
// SupportsReasoning: 0=none, 1=always-on, 2=toggleable
|
||||
SupportsReasoning int `json:"supports_reasoning"`
|
||||
SuitableApi string `json:"suitable_api"`
|
||||
SupportsReasoning int `json:"supports_reasoning"`
|
||||
SuitableApi string `json:"suitable_api"`
|
||||
CacheWritePrice float64 `json:"cache_write_price,omitempty"`
|
||||
CacheReadPrice float64 `json:"cache_read_price,omitempty"`
|
||||
ReasoningConfig map[string]interface{} `json:"reasoning_config,omitempty"`
|
||||
CacheType string `json:"cache_type,omitempty"`
|
||||
ModelType string `json:"model_type,omitempty"`
|
||||
SupportsFunctionCalling bool `json:"supports_function_calling,omitempty"`
|
||||
SupportsComputerUse bool `json:"supports_computer_use,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
// IsCustom: true if this model config is manually configured
|
||||
IsCustom bool `json:"is_custom"`
|
||||
}
|
||||
|
||||
// ModelConfigRequest is the request format for saving model config
|
||||
type ModelConfigRequest struct {
|
||||
Models []ModelCatalogItem `json:"models"`
|
||||
}
|
||||
|
||||
// zenmuxModel is the raw response shape from Zenmux listByFilter API.
|
||||
@ -278,8 +295,280 @@ var (
|
||||
}
|
||||
return "2uF44yawHUs41edv2fRcV_eE"
|
||||
}()
|
||||
|
||||
// Custom model config storage
|
||||
customModelConfig map[string]ModelCatalogItem
|
||||
customModelConfigMu sync.RWMutex
|
||||
)
|
||||
|
||||
// loadCustomModelConfig loads the custom model config from database
|
||||
func loadCustomModelConfig() {
|
||||
customModelConfigMu.Lock()
|
||||
defer customModelConfigMu.Unlock()
|
||||
|
||||
customModelConfig = make(map[string]ModelCatalogItem)
|
||||
rawValue, err := readModelConfigOptionValue()
|
||||
if err == nil && rawValue != "" && rawValue != "{}" && rawValue != "null" {
|
||||
items, parseErr := decodeLegacyModelConfig(rawValue)
|
||||
if parseErr == nil {
|
||||
for _, item := range items {
|
||||
normalized, ok := normalizeCustomModelConfigItem(item)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
customModelConfig[normalized.Id] = normalized
|
||||
}
|
||||
} else {
|
||||
logger.SysError(fmt.Sprintf("failed to parse ModelConfig from options table: %v", parseErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readModelConfigOptionValue() (string, error) {
|
||||
keys := []string{"ModelConfig", "model_config", "modelconfig"}
|
||||
for _, key := range keys {
|
||||
option := model.Option{Key: key}
|
||||
err := model.DB.First(&option, "key = ?", key).Error
|
||||
if err == nil {
|
||||
return strings.TrimSpace(option.Value), nil
|
||||
}
|
||||
}
|
||||
option := model.Option{}
|
||||
if err := model.DB.
|
||||
Where("LOWER(`key`) IN ?", []string{"modelconfig", "model_config"}).
|
||||
Order("id DESC").
|
||||
First(&option).Error; err == nil {
|
||||
return strings.TrimSpace(option.Value), nil
|
||||
}
|
||||
// Fallback: OptionMap is hydrated from DB at startup. If DB exact-key lookup
|
||||
// misses due unexpected key shape/collation issues, keep service usable.
|
||||
config.OptionMapRWMutex.RLock()
|
||||
defer config.OptionMapRWMutex.RUnlock()
|
||||
if v, ok := config.OptionMap["ModelConfig"]; ok {
|
||||
return strings.TrimSpace(v), nil
|
||||
}
|
||||
for k, v := range config.OptionMap {
|
||||
nk := strings.ToLower(strings.TrimSpace(k))
|
||||
if nk == "modelconfig" || nk == "model_config" {
|
||||
return strings.TrimSpace(v), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("ModelConfig option not found")
|
||||
}
|
||||
|
||||
// GetCustomModelConfig returns all custom model configurations
|
||||
func GetCustomModelConfig() map[string]ModelCatalogItem {
|
||||
customModelConfigMu.RLock()
|
||||
defer customModelConfigMu.RUnlock()
|
||||
result := make(map[string]ModelCatalogItem)
|
||||
for k, v := range customModelConfig {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SaveCustomModelConfig saves the custom model config to database and memory
|
||||
func SaveCustomModelConfig(models []ModelCatalogItem) error {
|
||||
customModelConfigMu.Lock()
|
||||
defer customModelConfigMu.Unlock()
|
||||
|
||||
if models == nil {
|
||||
models = []ModelCatalogItem{}
|
||||
}
|
||||
|
||||
configMap := make(map[string]ModelCatalogItem)
|
||||
normalizedModels := make([]ModelCatalogItem, 0, len(models))
|
||||
for _, item := range models {
|
||||
normalized, ok := normalizeCustomModelConfigItem(item)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
configMap[normalized.Id] = normalized
|
||||
normalizedModels = append(normalizedModels, normalized)
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(normalizedModels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal model config failed: %w", err)
|
||||
}
|
||||
|
||||
if err := model.UpdateOption("ModelConfig", string(jsonBytes)); err != nil {
|
||||
return fmt.Errorf("save model config failed: %w", err)
|
||||
}
|
||||
|
||||
customModelConfig = configMap
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeModalities(modalities []string, defaultValue string) []string {
|
||||
out := make([]string, 0, len(modalities))
|
||||
for _, modality := range modalities {
|
||||
m := strings.ToLower(strings.TrimSpace(modality))
|
||||
if m == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
if len(out) == 0 && defaultValue != "" {
|
||||
return []string{defaultValue}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeCustomModelConfigItem(item ModelCatalogItem) (ModelCatalogItem, bool) {
|
||||
item.Id = strings.TrimSpace(item.Id)
|
||||
if item.Id == "" {
|
||||
return ModelCatalogItem{}, false
|
||||
}
|
||||
item.Name = strings.TrimSpace(item.Name)
|
||||
if item.Name == "" {
|
||||
item.Name = item.Id
|
||||
}
|
||||
item.Provider = strings.TrimSpace(item.Provider)
|
||||
if item.Provider == "" {
|
||||
item.Provider = "其他"
|
||||
}
|
||||
item.Description = strings.TrimSpace(item.Description)
|
||||
item.InputModalities = normalizeModalities(item.InputModalities, "text")
|
||||
item.OutputModalities = normalizeModalities(item.OutputModalities, "text")
|
||||
item.SuitableApi = strings.TrimSpace(item.SuitableApi)
|
||||
item.PricingDiscount = strings.TrimSpace(item.PricingDiscount)
|
||||
if item.PricingDiscount == "" {
|
||||
item.PricingDiscount = "1"
|
||||
}
|
||||
item.CacheType = strings.TrimSpace(item.CacheType)
|
||||
if item.CacheType == "" {
|
||||
item.CacheType = "none"
|
||||
}
|
||||
item.ModelType = strings.TrimSpace(item.ModelType)
|
||||
if item.ModelType == "" {
|
||||
item.ModelType = "chat"
|
||||
}
|
||||
item.Icon = strings.TrimSpace(item.Icon)
|
||||
if item.Icon == "" {
|
||||
item.Icon = "auto"
|
||||
}
|
||||
if item.ContextLength < 0 {
|
||||
item.ContextLength = 0
|
||||
}
|
||||
if item.MaxOutputTokens < 0 {
|
||||
item.MaxOutputTokens = 0
|
||||
}
|
||||
item.IsCustom = true
|
||||
return item, true
|
||||
}
|
||||
|
||||
func decodeLegacyModelConfig(raw string) ([]ModelCatalogItem, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" || trimmed == "null" || trimmed == "{}" {
|
||||
return []ModelCatalogItem{}, nil
|
||||
}
|
||||
|
||||
// Legacy format C: JSON string that wraps another JSON payload, e.g. "\"[{...}]\""
|
||||
var nested string
|
||||
if err := json.Unmarshal([]byte(trimmed), &nested); err == nil && strings.TrimSpace(nested) != "" {
|
||||
nestedTrimmed := strings.TrimSpace(nested)
|
||||
// Avoid infinite recursion on plain non-JSON strings.
|
||||
if nestedTrimmed != trimmed && (strings.HasPrefix(nestedTrimmed, "[") || strings.HasPrefix(nestedTrimmed, "{")) {
|
||||
return decodeLegacyModelConfig(nestedTrimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// Current format: JSON array
|
||||
var items []ModelCatalogItem
|
||||
if err := json.Unmarshal([]byte(trimmed), &items); err == nil {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Legacy format A: {"models":[...]}
|
||||
var wrapped struct {
|
||||
Models []ModelCatalogItem `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(trimmed), &wrapped); err == nil && wrapped.Models != nil {
|
||||
return wrapped.Models, nil
|
||||
}
|
||||
|
||||
// Legacy format B: {"model-id": {...}}
|
||||
var keyed map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(trimmed), &keyed); err == nil {
|
||||
out := make([]ModelCatalogItem, 0, len(keyed))
|
||||
for key, val := range keyed {
|
||||
var item ModelCatalogItem
|
||||
if err := json.Unmarshal(val, &item); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(item.Id) == "" {
|
||||
item.Id = key
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported ModelConfig JSON format")
|
||||
}
|
||||
|
||||
func previewText(s string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(s) <= limit {
|
||||
return s
|
||||
}
|
||||
return s[:limit]
|
||||
}
|
||||
|
||||
// GetModelConfigDebug returns raw option value and parsed result summary.
|
||||
// GET /api/model/config/debug - admin only
|
||||
func GetModelConfigDebug(c *gin.Context) {
|
||||
rawValue, err := readModelConfigOptionValue()
|
||||
optionFound := err == nil
|
||||
config.OptionMapRWMutex.RLock()
|
||||
_, optionMapHasModelConfig := config.OptionMap["ModelConfig"]
|
||||
config.OptionMapRWMutex.RUnlock()
|
||||
decodedCount := 0
|
||||
parseError := ""
|
||||
sampleIds := make([]string, 0, 5)
|
||||
if optionFound {
|
||||
if items, parseErr := decodeLegacyModelConfig(rawValue); parseErr == nil {
|
||||
decodedCount = len(items)
|
||||
for i, item := range items {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
sampleIds = append(sampleIds, item.Id)
|
||||
}
|
||||
} else {
|
||||
parseError = parseErr.Error()
|
||||
}
|
||||
}
|
||||
loadCustomModelConfig()
|
||||
custom := GetCustomModelConfig()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": gin.H{
|
||||
"option_found": optionFound,
|
||||
"option_error": func() string {
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
"option_map_has_model_config": optionMapHasModelConfig,
|
||||
"raw_length": len(rawValue),
|
||||
"raw_preview": previewText(rawValue, 400),
|
||||
"decoded_count": decodedCount,
|
||||
"parse_error": parseError,
|
||||
"sample_ids": sampleIds,
|
||||
"loaded_count": len(custom),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
@ -342,6 +631,7 @@ func zenmuxToItem(z zenmuxModel) ModelCatalogItem {
|
||||
Tags: tags,
|
||||
SupportsReasoning: z.SupportsReasoning,
|
||||
SuitableApi: z.SuitableApi,
|
||||
IsCustom: false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -433,8 +723,27 @@ func GetModelCatalog(c *gin.Context) {
|
||||
available[m] = true
|
||||
}
|
||||
|
||||
// Load custom config and merge with zenmux catalog
|
||||
loadCustomModelConfig()
|
||||
customConfig := GetCustomModelConfig()
|
||||
|
||||
catalog, err := fetchZenmuxCatalog()
|
||||
if err != nil {
|
||||
// If zenmux fails, try to use only custom config
|
||||
if len(customConfig) > 0 {
|
||||
result := make([]ModelCatalogItem, 0)
|
||||
for _, item := range customConfig {
|
||||
if available[item.Id] {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "using custom config only: " + err.Error(),
|
||||
"data": result,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "model catalog unavailable: " + err.Error(),
|
||||
@ -443,8 +752,31 @@ func GetModelCatalog(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]ModelCatalogItem, 0, len(catalog))
|
||||
// Merge custom config with zenmux catalog
|
||||
mergedCatalog := make([]ModelCatalogItem, 0, len(catalog))
|
||||
zenmuxMap := make(map[string]ModelCatalogItem)
|
||||
for _, item := range catalog {
|
||||
zenmuxMap[item.Id] = item
|
||||
}
|
||||
|
||||
// Create merged list with custom overrides
|
||||
for _, item := range catalog {
|
||||
if custom, hasCustom := customConfig[item.Id]; hasCustom {
|
||||
mergedCatalog = append(mergedCatalog, custom)
|
||||
} else {
|
||||
mergedCatalog = append(mergedCatalog, item)
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom-only models (not in zenmux)
|
||||
for _, item := range customConfig {
|
||||
if _, exists := zenmuxMap[item.Id]; !exists {
|
||||
mergedCatalog = append(mergedCatalog, item)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]ModelCatalogItem, 0, len(mergedCatalog))
|
||||
for _, item := range mergedCatalog {
|
||||
if available[item.Id] {
|
||||
result = append(result, item)
|
||||
}
|
||||
@ -565,13 +897,13 @@ func SetupZenmuxChannels(c *gin.Context) {
|
||||
priority := int64(0)
|
||||
weight := uint(1)
|
||||
ch := model.Channel{
|
||||
Type: def.Type,
|
||||
Name: def.Name,
|
||||
Key: req.Key,
|
||||
BaseURL: &baseURL,
|
||||
Models: modelList,
|
||||
Status: 1,
|
||||
Group: "default",
|
||||
Type: def.Type,
|
||||
Name: def.Name,
|
||||
Key: req.Key,
|
||||
BaseURL: &baseURL,
|
||||
Models: modelList,
|
||||
Status: 1,
|
||||
Group: "default",
|
||||
Priority: &priority,
|
||||
Weight: &weight,
|
||||
}
|
||||
@ -592,8 +924,9 @@ func SetupZenmuxChannels(c *gin.Context) {
|
||||
// SyncZenmuxModels fetches all models from Zenmux and updates the channel's model list.
|
||||
// POST /api/channel/:id/sync-zenmux
|
||||
// Optional query param: ?protocol=chat.completions
|
||||
// Accepts comma-separated values to match multiple protocols, e.g.
|
||||
// ?protocol=google.gemini,google.imagen,google.video
|
||||
//
|
||||
// Accepts comma-separated values to match multiple protocols, e.g.
|
||||
// ?protocol=google.gemini,google.imagen,google.video
|
||||
func SyncZenmuxModels(c *gin.Context) {
|
||||
channelId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
@ -732,3 +1065,149 @@ func SyncZenmuxRatios(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// --- Model Config Management (Custom Manual Configuration) ---
|
||||
|
||||
// GetModelConfigManagement returns all model configs (both custom and zenmux)
|
||||
// GET /api/model/config - admin only
|
||||
func GetModelConfigManagement(c *gin.Context) {
|
||||
// Load custom config
|
||||
loadCustomModelConfig()
|
||||
customConfig := GetCustomModelConfig()
|
||||
|
||||
// Get zenmux catalog as base
|
||||
catalog, err := fetchZenmuxCatalog()
|
||||
if err != nil {
|
||||
// If zenmux fails, just return custom config
|
||||
result := make([]ModelCatalogItem, 0, len(customConfig))
|
||||
for _, item := range customConfig {
|
||||
result = append(result, item)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "zenmux unavailable: " + err.Error(),
|
||||
"data": result,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Merge: custom config overrides zenmux config
|
||||
result := make([]ModelCatalogItem, 0, len(catalog))
|
||||
zenmuxMap := make(map[string]ModelCatalogItem)
|
||||
for _, item := range catalog {
|
||||
zenmuxMap[item.Id] = item
|
||||
}
|
||||
|
||||
// Add all zenmux models first, with custom overrides
|
||||
for _, item := range catalog {
|
||||
if custom, hasCustom := customConfig[item.Id]; hasCustom {
|
||||
result = append(result, custom)
|
||||
} else {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom-only models (not in zenmux)
|
||||
for _, item := range customConfig {
|
||||
if _, exists := zenmuxMap[item.Id]; !exists {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
|
||||
// SaveModelConfig saves custom model configurations
|
||||
// POST /api/model/config - admin only
|
||||
func SaveModelConfig(c *gin.Context) {
|
||||
var req ModelConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Models == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "models is required and must be an array"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := SaveCustomModelConfig(req.Models); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("saved %d model configs", len(req.Models)),
|
||||
})
|
||||
}
|
||||
|
||||
// GetModelConfigCustom returns only custom model configs
|
||||
// GET /api/model/config/custom - admin only
|
||||
func GetModelConfigCustom(c *gin.Context) {
|
||||
loadCustomModelConfig()
|
||||
customConfig := GetCustomModelConfig()
|
||||
|
||||
result := make([]ModelCatalogItem, 0, len(customConfig))
|
||||
for _, item := range customConfig {
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
|
||||
// ImportModelConfig imports model configs from JSON
|
||||
// POST /api/model/config/import - admin only
|
||||
func ImportModelConfig(c *gin.Context) {
|
||||
var req ModelConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid JSON format"})
|
||||
return
|
||||
}
|
||||
|
||||
loadCustomModelConfig()
|
||||
existingConfig := GetCustomModelConfig()
|
||||
|
||||
// Merge with existing config
|
||||
for _, model := range req.Models {
|
||||
model.IsCustom = true
|
||||
existingConfig[model.Id] = model
|
||||
}
|
||||
|
||||
// Convert back to slice
|
||||
result := make([]ModelCatalogItem, 0, len(existingConfig))
|
||||
for _, item := range existingConfig {
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
if err := SaveCustomModelConfig(result); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("imported %d model configs", len(req.Models)),
|
||||
})
|
||||
}
|
||||
|
||||
// ExportModelConfig exports model configs as JSON
|
||||
// GET /api/model/config/export - admin only
|
||||
func ExportModelConfig(c *gin.Context) {
|
||||
loadCustomModelConfig()
|
||||
customConfig := GetCustomModelConfig()
|
||||
|
||||
result := make([]ModelCatalogItem, 0, len(customConfig))
|
||||
for _, item := range customConfig {
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", "attachment; filename=model-config.json")
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/songquanpeng/one-api/common"
|
||||
@ -49,6 +50,17 @@ func Relay(c *gin.Context) {
|
||||
requestBody, _ := common.GetRequestBody(c)
|
||||
logger.Debugf(ctx, "request body: %s", string(requestBody))
|
||||
}
|
||||
// Debug logging for multimodal requests - always log to help diagnose image issues
|
||||
requestBody, _ := common.GetRequestBody(c)
|
||||
logger.Infof(ctx, "=== Request received: Path=%s, BodyLength=%d ===", c.Request.URL.Path, len(requestBody))
|
||||
if len(requestBody) > 0 && len(requestBody) < 5000 {
|
||||
bodyStr := string(requestBody)
|
||||
logger.Debugf(ctx, "Request body: %s", bodyStr)
|
||||
// Detect multimodal content
|
||||
if strings.Contains(bodyStr, "image_url") {
|
||||
logger.Infof(ctx, "=== MULTIMODAL REQUEST DETECTED: Request contains image_url ===")
|
||||
}
|
||||
}
|
||||
channelId := c.GetInt(ctxkey.ChannelId)
|
||||
userId := c.GetInt(ctxkey.Id)
|
||||
bizErr := relayHelper(c, relayMode)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/songquanpeng/one-api/common/config"
|
||||
"github.com/songquanpeng/one-api/common/logger"
|
||||
billingratio "github.com/songquanpeng/one-api/relay/billing/ratio"
|
||||
@ -106,12 +107,16 @@ func UpdateOption(key string, value string) error {
|
||||
Key: key,
|
||||
}
|
||||
// https://gorm.io/docs/update.html#Save-All-Fields
|
||||
DB.FirstOrCreate(&option, Option{Key: key})
|
||||
if err := DB.FirstOrCreate(&option, Option{Key: key}).Error; err != nil {
|
||||
return fmt.Errorf("create option %s failed: %w", key, err)
|
||||
}
|
||||
option.Value = value
|
||||
// Save is a combination function.
|
||||
// If save value does not contain primary key, it will execute Create,
|
||||
// otherwise it will execute Update (with all fields).
|
||||
DB.Save(&option)
|
||||
if err := DB.Save(&option).Error; err != nil {
|
||||
return fmt.Errorf("save option %s failed: %w", key, err)
|
||||
}
|
||||
// Update OptionMap
|
||||
return updateOptionMap(key, value)
|
||||
}
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/songquanpeng/one-api/common/logger"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content any `json:"content,omitempty"`
|
||||
@ -51,11 +55,14 @@ func (m Message) ParseContent() []MessageContent {
|
||||
}
|
||||
anyList, ok := m.Content.([]any)
|
||||
if ok {
|
||||
for _, contentItem := range anyList {
|
||||
logger.Debugf(nil, "=== ParseContent: Found array content with %d items ===", len(anyList))
|
||||
for i, contentItem := range anyList {
|
||||
contentMap, ok := contentItem.(map[string]any)
|
||||
if !ok {
|
||||
logger.Debugf(nil, "=== ParseContent: Item %d is not a map, type=%T ===", i, contentItem)
|
||||
continue
|
||||
}
|
||||
logger.Debugf(nil, "=== ParseContent: Item %d has type=%v ===", i, contentMap["type"])
|
||||
switch contentMap["type"] {
|
||||
case ContentTypeText:
|
||||
if subStr, ok := contentMap["text"].(string); ok {
|
||||
@ -65,13 +72,21 @@ func (m Message) ParseContent() []MessageContent {
|
||||
})
|
||||
}
|
||||
case ContentTypeImageURL:
|
||||
logger.Debugf(nil, "=== ParseContent: Processing image_url item ===")
|
||||
if subObj, ok := contentMap["image_url"].(map[string]any); ok {
|
||||
contentList = append(contentList, MessageContent{
|
||||
Type: ContentTypeImageURL,
|
||||
ImageURL: &ImageURL{
|
||||
Url: subObj["url"].(string),
|
||||
},
|
||||
})
|
||||
if url, ok := subObj["url"].(string); ok {
|
||||
logger.Debugf(nil, "=== ParseContent: Found image URL: %s ===", truncateURL(url))
|
||||
contentList = append(contentList, MessageContent{
|
||||
Type: ContentTypeImageURL,
|
||||
ImageURL: &ImageURL{
|
||||
Url: url,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
logger.Errorf(nil, "=== ParseContent: image_url.url is not a string, type=%T ===", subObj["url"])
|
||||
}
|
||||
} else {
|
||||
logger.Errorf(nil, "=== ParseContent: image_url is not a map, type=%T ===", contentMap["image_url"])
|
||||
}
|
||||
case ContentTypeVideoURL:
|
||||
if subObj, ok := contentMap["video_url"].(map[string]any); ok {
|
||||
@ -95,11 +110,26 @@ func (m Message) ParseContent() []MessageContent {
|
||||
}
|
||||
}
|
||||
}
|
||||
imageCount := 0
|
||||
for _, c := range contentList {
|
||||
if c.Type == ContentTypeImageURL {
|
||||
imageCount++
|
||||
}
|
||||
}
|
||||
logger.Debugf(nil, "=== ParseContent: Parsed %d content items, image_count=%d ===", len(contentList), imageCount)
|
||||
return contentList
|
||||
}
|
||||
logger.Debugf(nil, "=== ParseContent: Content is neither string nor array, type=%T ===", m.Content)
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncateURL(url string) string {
|
||||
if len(url) > 100 {
|
||||
return url[:100] + "..."
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
type ImageURL struct {
|
||||
Url string `json:"url,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
|
||||
@ -18,6 +18,15 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
|
||||
apiRouter.GET("/model-catalog", middleware.TokenAuth(), controller.GetModelCatalog)
|
||||
apiRouter.GET("/token-quota", middleware.TokenAuth(), controller.GetTokenQuota)
|
||||
|
||||
// Model Config Management (admin only)
|
||||
apiRouter.GET("/model/config", middleware.AdminAuth(), controller.GetModelConfigManagement)
|
||||
apiRouter.POST("/model/config", middleware.AdminAuth(), controller.SaveModelConfig)
|
||||
apiRouter.GET("/model/config/custom", middleware.AdminAuth(), controller.GetModelConfigCustom)
|
||||
apiRouter.GET("/model/config/debug", middleware.AdminAuth(), controller.GetModelConfigDebug)
|
||||
apiRouter.POST("/model/config/import", middleware.AdminAuth(), controller.ImportModelConfig)
|
||||
apiRouter.GET("/model/config/export", middleware.AdminAuth(), controller.ExportModelConfig)
|
||||
|
||||
apiRouter.POST("/channel/:id/sync-zenmux", middleware.AdminAuth(), controller.SyncZenmuxModels)
|
||||
apiRouter.POST("/zenmux/setup", middleware.AdminAuth(), controller.SetupZenmuxChannels)
|
||||
apiRouter.GET("/zenmux/protocols", middleware.AdminAuth(), controller.GetZenmuxProtocols)
|
||||
|
||||
955
web/berry/src/components/ModelConfigEditor.js
Normal file
955
web/berry/src/components/ModelConfigEditor.js
Normal file
@ -0,0 +1,955 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
IconButton,
|
||||
Chip,
|
||||
Box,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
LinearProgress,
|
||||
Autocomplete,
|
||||
Tab,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
Stack,
|
||||
Switch,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Save as SaveIcon,
|
||||
Add as AddIcon,
|
||||
Close as CloseIcon,
|
||||
Settings as SettingsIcon,
|
||||
Info as InfoIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon
|
||||
} from '@mui/icons-material';
|
||||
import { API } from 'utils/api';
|
||||
import { showError, showSuccess, showInfo } from 'utils/common';
|
||||
|
||||
// 提供商列表
|
||||
const PROVIDERS = [
|
||||
'xiaomi', 'bailian', 'aiscri-xiong', 'OpenAI', 'Anthropic', 'Google', 'DeepSeek',
|
||||
'Moonshot', 'MiniMax', '智谱 AI', '阿里云', '腾讯', '百度', '字节', '其他'
|
||||
];
|
||||
|
||||
// API类型(对应 suitable_api 字段)
|
||||
const API_OPTIONS = [
|
||||
{ value: 'openai-chat', label: 'OpenAI Chat Completions' },
|
||||
{ value: 'openai-completions', label: 'OpenAI Completions' },
|
||||
{ value: 'anthropic-messages', label: 'Anthropic Messages' },
|
||||
{ value: 'anthropic-responses', label: 'Anthropic Responses' },
|
||||
{ value: 'google-generate', label: 'Google Generate' },
|
||||
{ value: 'embeddings', label: 'Embeddings' },
|
||||
{ value: 'auto', label: '自动检测 (Auto)' }
|
||||
];
|
||||
|
||||
// 标签选项
|
||||
const TAG_OPTIONS = [
|
||||
'vision', 'function-call', 'reasoning', 'code', 'agent', 'multimodal',
|
||||
'fast', 'cheap', 'context-long', 'audio', 'video', 'file', 'tool-use',
|
||||
'computer-use', 'streaming', 'json-mode'
|
||||
];
|
||||
|
||||
// 模态选项
|
||||
const MODALITY_OPTIONS = ['text', 'image', 'audio', 'video', 'file'];
|
||||
|
||||
// 推理能力(对应 supports_reasoning 字段)
|
||||
const REASONING_OPTIONS = [
|
||||
{ value: 0, label: '不支持推理' },
|
||||
{ value: 1, label: '始终开启 (Always-on)' },
|
||||
{ value: 2, label: '可切换 (Toggleable)' }
|
||||
];
|
||||
|
||||
// 缓存类型选项
|
||||
const CACHE_TYPE_OPTIONS = [
|
||||
{ value: 'none', label: '不启用' },
|
||||
{ value: 'responses', label: 'Prompt 缓存(Responses)' },
|
||||
{ value: 'anthropic', label: 'System 缓存(Anthropic)' }
|
||||
];
|
||||
|
||||
// 模型类型选项(用于区分不同用途的模型)
|
||||
const MODEL_TYPE_OPTIONS = [
|
||||
{ value: 'chat', label: '对话 (Chat)' },
|
||||
{ value: 'completion', label: '文本补全 (Completion)' },
|
||||
{ value: 'embeddings', label: '向量嵌入 (Embeddings)' },
|
||||
{ value: 'image', label: '图像生成 (Image)' },
|
||||
{ value: 'audio', label: '语音 (Audio)' },
|
||||
{ value: 'multimodal', label: '多模态 (Multimodal)' }
|
||||
];
|
||||
|
||||
const ModelConfigEditor = ({ open, onClose }) => {
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [models, setModels] = useState([]);
|
||||
const [customModels, setCustomModels] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [editingModel, setEditingModel] = useState(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [formValues, setFormValues] = useState({});
|
||||
const [expandedSection, setExpandedSection] = useState('basic');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchKeyword('');
|
||||
fetchData();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const allRes = await API.get('/api/model/config');
|
||||
const customRes = await API.get('/api/model/config/custom');
|
||||
|
||||
if (allRes.data.success) {
|
||||
setModels(allRes.data.data || []);
|
||||
} else {
|
||||
setModels([]);
|
||||
showError(allRes.data.message || '加载模型配置失败');
|
||||
}
|
||||
if (customRes.data.success) {
|
||||
setCustomModels(customRes.data.data || []);
|
||||
} else {
|
||||
setCustomModels([]);
|
||||
showError(customRes.data.message || '加载自定义模型失败');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('加载模型配置失败: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveModelConfigs = async () => {
|
||||
const payload = customModels
|
||||
.map((m) => ({
|
||||
...m,
|
||||
id: (m.id || '').trim(),
|
||||
name: (m.name || '').trim() || (m.id || '').trim(),
|
||||
provider: (m.provider || '其他').trim(),
|
||||
input_modalities: Array.isArray(m.input_modalities) && m.input_modalities.length > 0
|
||||
? m.input_modalities
|
||||
: ['text'],
|
||||
output_modalities: Array.isArray(m.output_modalities) && m.output_modalities.length > 0
|
||||
? m.output_modalities
|
||||
: ['text'],
|
||||
pricing_discount: String(m.pricing_discount || '1'),
|
||||
suitable_api: (m.suitable_api || 'openai-chat').trim(),
|
||||
is_custom: true,
|
||||
}))
|
||||
.filter((m) => m.id);
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await API.post('/api/model/config', { models: payload });
|
||||
if (res.data.success) {
|
||||
showSuccess('保存成功');
|
||||
await fetchData();
|
||||
} else {
|
||||
showError(res.data.message || '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('保存失败: ' + error.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (model = null) => {
|
||||
setEditingModel(model);
|
||||
if (model) {
|
||||
setFormValues({
|
||||
id: model.id || '',
|
||||
name: model.name || '',
|
||||
provider: model.provider || '',
|
||||
description: model.description || '',
|
||||
input_price: model.input_price || 0,
|
||||
output_price: model.output_price || 0,
|
||||
cache_write_price: model.cache_write_price || 0,
|
||||
cache_read_price: model.cache_read_price || 0,
|
||||
context_length: model.context_length || 4096,
|
||||
max_output_tokens: model.max_output_tokens || 4096,
|
||||
suitable_api: model.suitable_api || 'openai-chat',
|
||||
input_modalities: model.input_modalities || ['text'],
|
||||
output_modalities: model.output_modalities || ['text'],
|
||||
pricing_discount: model.pricing_discount || '1',
|
||||
tags: model.tags || [],
|
||||
supports_reasoning: model.supports_reasoning ?? 0,
|
||||
reasoning_config: model.reasoning_config || {},
|
||||
cache_type: model.cache_type || 'none',
|
||||
model_type: model.model_type || 'chat',
|
||||
supports_function_calling: model.supports_function_calling || false,
|
||||
supports_computer_use: model.supports_computer_use || false,
|
||||
icon: model.icon || 'auto',
|
||||
});
|
||||
} else {
|
||||
setFormValues({
|
||||
id: '', name: '', provider: '', description: '',
|
||||
input_price: 0, output_price: 0, cache_write_price: 0, cache_read_price: 0,
|
||||
context_length: 4096, max_output_tokens: 4096,
|
||||
suitable_api: 'openai-chat',
|
||||
input_modalities: ['text'],
|
||||
output_modalities: ['text'],
|
||||
pricing_discount: '1',
|
||||
tags: [],
|
||||
supports_reasoning: 0,
|
||||
reasoning_config: {},
|
||||
cache_type: 'none',
|
||||
model_type: 'chat',
|
||||
supports_function_calling: false,
|
||||
supports_computer_use: false,
|
||||
icon: 'auto',
|
||||
});
|
||||
}
|
||||
setIsEditModalOpen(true);
|
||||
setExpandedSection('basic');
|
||||
};
|
||||
|
||||
const closeEditModal = () => {
|
||||
setEditingModel(null);
|
||||
setIsEditModalOpen(false);
|
||||
setFormValues({});
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
if (!formValues.id || !formValues.name) {
|
||||
showError('请填写模型ID和名称');
|
||||
return;
|
||||
}
|
||||
|
||||
const newModel = {
|
||||
id: formValues.id.trim(),
|
||||
name: formValues.name.trim(),
|
||||
provider: formValues.provider || '其他',
|
||||
description: formValues.description || '',
|
||||
input_price: parseFloat(formValues.input_price) || 0,
|
||||
output_price: parseFloat(formValues.output_price) || 0,
|
||||
cache_write_price: parseFloat(formValues.cache_write_price) || 0,
|
||||
cache_read_price: parseFloat(formValues.cache_read_price) || 0,
|
||||
context_length: parseInt(formValues.context_length) || 4096,
|
||||
max_output_tokens: parseInt(formValues.max_output_tokens) || 4096,
|
||||
suitable_api: formValues.suitable_api || 'openai-chat',
|
||||
input_modalities: Array.isArray(formValues.input_modalities) ? formValues.input_modalities : ['text'],
|
||||
output_modalities: Array.isArray(formValues.output_modalities) ? formValues.output_modalities : ['text'],
|
||||
pricing_discount: String(formValues.pricing_discount || '1'),
|
||||
tags: Array.isArray(formValues.tags) ? formValues.tags : [],
|
||||
supports_reasoning: parseInt(formValues.supports_reasoning) || 0,
|
||||
reasoning_config: formValues.reasoning_config || {},
|
||||
cache_type: formValues.cache_type || 'none',
|
||||
model_type: formValues.model_type || 'chat',
|
||||
supports_function_calling: formValues.supports_function_calling || false,
|
||||
supports_computer_use: formValues.supports_computer_use || false,
|
||||
icon: formValues.icon || 'auto',
|
||||
is_custom: true,
|
||||
};
|
||||
|
||||
let updated;
|
||||
if (editingModel) {
|
||||
const nextId = (newModel.id || '').trim();
|
||||
const oldId = (editingModel.id || '').trim();
|
||||
if (nextId !== oldId && customModels.some(m => m.id === nextId)) {
|
||||
showError('模型ID已存在');
|
||||
return;
|
||||
}
|
||||
updated = customModels.map(m => m.id === editingModel.id ? newModel : m);
|
||||
} else {
|
||||
if (customModels.some(m => m.id === newModel.id)) {
|
||||
showError('模型ID已存在');
|
||||
return;
|
||||
}
|
||||
updated = [...customModels, newModel];
|
||||
}
|
||||
|
||||
setCustomModels(updated);
|
||||
closeEditModal();
|
||||
showInfo('配置已更新,请点击"保存配置"按钮保存到服务器');
|
||||
};
|
||||
|
||||
const deleteModel = (modelId) => {
|
||||
const updated = customModels.filter(m => m.id !== modelId);
|
||||
setCustomModels(updated);
|
||||
showInfo('已删除,请点击"保存配置"按钮');
|
||||
};
|
||||
|
||||
const filteredModels = models.filter(m => {
|
||||
const k = searchKeyword.toLowerCase();
|
||||
return !searchKeyword ||
|
||||
m.id?.toLowerCase().includes(k) ||
|
||||
m.name?.toLowerCase().includes(k) ||
|
||||
m.provider?.toLowerCase().includes(k);
|
||||
});
|
||||
|
||||
const filteredCustom = customModels.filter(m => {
|
||||
const k = searchKeyword.toLowerCase();
|
||||
return !searchKeyword ||
|
||||
m.id?.toLowerCase().includes(k) ||
|
||||
m.name?.toLowerCase().includes(k);
|
||||
});
|
||||
|
||||
const handleExpandSection = (section) => (event, isExpanded) => {
|
||||
setExpandedSection(isExpanded ? section : false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth PaperProps={{ sx: { height: '85vh' } }}>
|
||||
<DialogTitle>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<SettingsIcon />
|
||||
<Typography variant="h6">模型配置管理</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Tooltip title="修改后需要点击保存按钮才能持久化到数据库">
|
||||
<InfoIcon color="info" fontSize="small" />
|
||||
</Tooltip>
|
||||
<IconButton onClick={onClose} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ pb: 2 }}>
|
||||
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tab label={`所有模型 (${models.length})`} />
|
||||
<Tab label={`自定义配置 (${customModels.length})`} />
|
||||
</Tabs>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center" py={2}>
|
||||
<TextField
|
||||
placeholder="搜索模型ID、名称、提供商..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment>,
|
||||
}}
|
||||
size="small"
|
||||
sx={{ flexGrow: 1, maxWidth: 300 }}
|
||||
/>
|
||||
{tabValue === 1 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => openEditModal()}
|
||||
size="small"
|
||||
>
|
||||
添加模型
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Tab 1: 所有模型 */}
|
||||
<Box sx={{ display: tabValue === 0 ? 'block' : 'none' }}>
|
||||
{loading ? (
|
||||
<LinearProgress />
|
||||
) : (
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 450 }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>模型ID</TableCell>
|
||||
<TableCell>名称</TableCell>
|
||||
<TableCell>提供商</TableCell>
|
||||
<TableCell align="right">输入价</TableCell>
|
||||
<TableCell align="right">输出价</TableCell>
|
||||
<TableCell align="right">上下文</TableCell>
|
||||
<TableCell>模态</TableCell>
|
||||
<TableCell>标签</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredModels.map((model) => (
|
||||
<TableRow key={model.id} hover>
|
||||
<TableCell>
|
||||
<Typography variant="caption" fontFamily="monospace">
|
||||
{model.id}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{model.name || '-'}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={model.provider || '-'} size="small" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell align="right">{model.input_price || '-'}</TableCell>
|
||||
<TableCell align="right">{model.output_price || '-'}</TableCell>
|
||||
<TableCell align="right">
|
||||
{model.context_length ? (
|
||||
<Typography variant="caption">
|
||||
{model.context_length >= 1000
|
||||
? `${(model.context_length / 1000).toFixed(0)}k`
|
||||
: model.context_length}
|
||||
</Typography>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box display="flex" gap={0.5} flexWrap="wrap">
|
||||
{(model.input_modalities || []).map(m => (
|
||||
<Chip key={m} label={m} size="small" sx={{ height: 18, fontSize: '0.65rem' }} />
|
||||
))}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box display="flex" gap={0.5} flexWrap="wrap">
|
||||
{(model.tags || []).map(tag => (
|
||||
<Chip key={tag} label={tag} size="small" variant="outlined" sx={{ height: 18, fontSize: '0.65rem' }} />
|
||||
))}
|
||||
{model.supports_reasoning > 0 && (
|
||||
<Chip label="推理" size="small" color="secondary" sx={{ height: 18, fontSize: '0.65rem' }} />
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Tab 2: 自定义配置 */}
|
||||
<Box sx={{ display: tabValue === 1 ? 'block' : 'none' }}>
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 450 }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>模型ID</TableCell>
|
||||
<TableCell>名称</TableCell>
|
||||
<TableCell>提供商</TableCell>
|
||||
<TableCell align="right">价格(入/出)</TableCell>
|
||||
<TableCell align="right">上下文</TableCell>
|
||||
<TableCell>模态</TableCell>
|
||||
<TableCell>标签</TableCell>
|
||||
<TableCell align="center">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredCustom.map((model) => (
|
||||
<TableRow key={model.id} hover sx={{ backgroundColor: 'rgba(25, 118, 210, 0.04)' }}>
|
||||
<TableCell>
|
||||
<Typography variant="caption" fontFamily="monospace">
|
||||
{model.id}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{model.name}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={model.provider || '其他'} size="small" />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography variant="caption">
|
||||
{model.input_price}/{model.output_price}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography variant="caption">
|
||||
{model.context_length
|
||||
? (model.context_length >= 1000
|
||||
? `${(model.context_length / 1000).toFixed(0)}k`
|
||||
: model.context_length)
|
||||
: '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" noWrap sx={{ maxWidth: 100 }}>
|
||||
{(model.input_modalities || []).join(', ')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box display="flex" gap={0.5} flexWrap="wrap">
|
||||
{(model.tags || []).slice(0, 3).map(tag => (
|
||||
<Chip key={tag} label={tag} size="small" variant="filled" color="primary" sx={{ height: 18, fontSize: '0.65rem' }} />
|
||||
))}
|
||||
{(model.tags || []).length > 3 && (
|
||||
<Chip label={`+${model.tags.length - 3}`} size="small" sx={{ height: 18, fontSize: '0.65rem' }} />
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton size="small" onClick={() => openEditModal(model)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => deleteModel(model.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredCustom.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary">
|
||||
暂无自定义模型配置,点击"添加模型"创建新配置
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2, justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
{tabValue === 1 && customModels.length > 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
已配置 {customModels.length} 个自定义模型,修改后点击"保存配置"持久化到数据库
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
disabled={saving || customModels.length === 0}
|
||||
onClick={saveModelConfigs}
|
||||
>
|
||||
{saving ? '保存中...' : `保存配置 (${customModels.length})`}
|
||||
</Button>
|
||||
</Stack>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 编辑/添加模型对话框 */}
|
||||
<Dialog open={isEditModalOpen} onClose={closeEditModal} maxWidth="md" fullWidth scroll="paper">
|
||||
<DialogTitle>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="h6">
|
||||
{editingModel ? '编辑模型配置' : '添加模型配置'}
|
||||
</Typography>
|
||||
<IconButton onClick={closeEditModal} size="small"><CloseIcon /></IconButton>
|
||||
</Stack>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<Accordion expanded={expandedSection === 'basic'} onChange={handleExpandSection('basic')}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">基本信息</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth label="模型 ID *"
|
||||
value={formValues.id || ''}
|
||||
onChange={(e) => setFormValues({ ...formValues, id: e.target.value })}
|
||||
size="small"
|
||||
placeholder="例如: MiniMax/MiniMax-M2.7"
|
||||
helperText={editingModel ? "可修改;修改后会按新ID保存并覆盖当前条目" : "唯一标识符,格式: provider/model-name"}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth label="显示名称 *"
|
||||
value={formValues.name || ''}
|
||||
onChange={(e) => setFormValues({ ...formValues, name: e.target.value })}
|
||||
size="small"
|
||||
placeholder="例如: MiniMax-M2.7"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Autocomplete
|
||||
freeSolo options={PROVIDERS}
|
||||
value={formValues.provider || ''}
|
||||
onChange={(e, v) => setFormValues({ ...formValues, provider: v })}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} fullWidth label="提供商" size="small" />
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>模型类型</InputLabel>
|
||||
<Select
|
||||
value={formValues.model_type || 'chat'}
|
||||
label="模型类型"
|
||||
onChange={(e) => setFormValues({ ...formValues, model_type: e.target.value })}
|
||||
>
|
||||
{MODEL_TYPE_OPTIONS.map(t => (
|
||||
<MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>用于区分对话/语音/嵌入/图像模型</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth multiline rows={2}
|
||||
label="模型描述"
|
||||
value={formValues.description || ''}
|
||||
onChange={(e) => setFormValues({ ...formValues, description: e.target.value })}
|
||||
size="small"
|
||||
placeholder="模型功能和特点描述"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* 请求协议 */}
|
||||
<Accordion expanded={expandedSection === 'protocol'} onChange={handleExpandSection('protocol')}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">请求协议</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>协议覆盖</InputLabel>
|
||||
<Select
|
||||
value={formValues.suitable_api || 'openai-chat'}
|
||||
label="协议覆盖"
|
||||
onChange={(e) => setFormValues({ ...formValues, suitable_api: e.target.value })}
|
||||
>
|
||||
<MenuItem value="">不选则使用提供商默认协议</MenuItem>
|
||||
{API_OPTIONS.map(t => (
|
||||
<MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
当前: {formValues.suitable_api || 'openai-chat'}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} justifyContent="space-between">
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Switch
|
||||
checked={formValues.use_default_model_type !== false}
|
||||
onChange={(e) => setFormValues({ ...formValues, use_default_model_type: e.target.checked })}
|
||||
size="small"
|
||||
/>
|
||||
<Typography variant="body2">使用提供商默认模型类型</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
用于区分对话/语音/嵌入/图像模型
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* 上下文与输出 */}
|
||||
<Accordion expanded={expandedSection === 'context'} onChange={handleExpandSection('context')}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">上下文与输出</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth type="number"
|
||||
label="对话上下文长度"
|
||||
value={formValues.context_length || 4096}
|
||||
onChange={(e) => setFormValues({ ...formValues, context_length: e.target.value })}
|
||||
size="small"
|
||||
helperText="tokens,如: 200000"
|
||||
inputProps={{ step: 1, min: 0 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth type="number"
|
||||
label="最大输出 Token"
|
||||
value={formValues.max_output_tokens || 4096}
|
||||
onChange={(e) => setFormValues({ ...formValues, max_output_tokens: e.target.value })}
|
||||
size="small"
|
||||
helperText="tokens,如: 128000"
|
||||
inputProps={{ step: 1, min: 0 }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* 定价配置 */}
|
||||
<Accordion expanded={expandedSection === 'pricing'} onChange={handleExpandSection('pricing')}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">定价 (USD / 百万 Token)</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField
|
||||
fullWidth type="number"
|
||||
label="输入价格"
|
||||
value={formValues.input_price || 0}
|
||||
onChange={(e) => setFormValues({ ...formValues, input_price: e.target.value })}
|
||||
size="small"
|
||||
helperText="$/1M tokens"
|
||||
inputProps={{ step: 0.01, min: 0 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField
|
||||
fullWidth type="number"
|
||||
label="输出价格"
|
||||
value={formValues.output_price || 0}
|
||||
onChange={(e) => setFormValues({ ...formValues, output_price: e.target.value })}
|
||||
size="small"
|
||||
helperText="$/1M tokens"
|
||||
inputProps={{ step: 0.01, min: 0 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField
|
||||
fullWidth type="number"
|
||||
label="缓存写入价格"
|
||||
value={formValues.cache_write_price || 0}
|
||||
onChange={(e) => setFormValues({ ...formValues, cache_write_price: e.target.value })}
|
||||
size="small"
|
||||
helperText="$/1M tokens"
|
||||
inputProps={{ step: 0.01, min: 0 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField
|
||||
fullWidth type="number"
|
||||
label="缓存命中价格"
|
||||
value={formValues.cache_read_price || 0}
|
||||
onChange={(e) => setFormValues({ ...formValues, cache_read_price: e.target.value })}
|
||||
size="small"
|
||||
helperText="$/1M tokens"
|
||||
inputProps={{ step: 0.01, min: 0 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth type="number"
|
||||
label="折扣"
|
||||
value={formValues.pricing_discount || '1'}
|
||||
onChange={(e) => setFormValues({ ...formValues, pricing_discount: e.target.value })}
|
||||
size="small"
|
||||
helperText="如: 0.8 表示8折"
|
||||
inputProps={{ step: 0.01, min: 0, max: 1 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="图标"
|
||||
value={formValues.icon || 'auto'}
|
||||
onChange={(e) => setFormValues({ ...formValues, icon: e.target.value })}
|
||||
size="small"
|
||||
helperText="auto 或图标 URL"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Responses 配置 */}
|
||||
<Accordion expanded={expandedSection === 'responses'} onChange={handleExpandSection('responses')}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">Responses 配置</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>推理摘要</InputLabel>
|
||||
<Select
|
||||
value={formValues.reasoning_mode || 'disabled'}
|
||||
label="推理摘要"
|
||||
onChange={(e) => setFormValues({ ...formValues, reasoning_mode: e.target.value })}
|
||||
>
|
||||
<MenuItem value="disabled">不启用</MenuItem>
|
||||
<MenuItem value="auto">自动</MenuItem>
|
||||
<MenuItem value="enabled">始终启用</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Prompt 缓存</InputLabel>
|
||||
<Select
|
||||
value={formValues.cache_type || 'none'}
|
||||
label="Prompt 缓存"
|
||||
onChange={(e) => setFormValues({ ...formValues, cache_type: e.target.value })}
|
||||
>
|
||||
{CACHE_TYPE_OPTIONS.map(t => (
|
||||
<MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* 能力配置 */}
|
||||
<Accordion expanded={expandedSection === 'capabilities'} onChange={handleExpandSection('capabilities')}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">能力</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom>Input modalities</Typography>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={MODALITY_OPTIONS}
|
||||
value={formValues.input_modalities || ['text']}
|
||||
onChange={(e, v) => setFormValues({ ...formValues, input_modalities: v })}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip variant="outlined" label={option} size="small" {...getTagProps({ index })} />
|
||||
))
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
fullWidth
|
||||
placeholder="选择支持的输入类型"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom>Output modalities</Typography>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={MODALITY_OPTIONS}
|
||||
value={formValues.output_modalities || ['text']}
|
||||
onChange={(e, v) => setFormValues({ ...formValues, output_modalities: v })}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip variant="outlined" label={option} size="small" {...getTagProps({ index })} />
|
||||
))
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
fullWidth
|
||||
placeholder="选择支持的输出类型"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="body2">支持函数调用</Typography>
|
||||
<Switch
|
||||
checked={formValues.supports_function_calling || false}
|
||||
onChange={(e) => setFormValues({ ...formValues, supports_function_calling: e.target.checked })}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography variant="body2">支持 Computer Use</Typography>
|
||||
<Tooltip title="启用后模型可以执行计算机操作">
|
||||
<InfoIcon fontSize="small" color="info" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Switch
|
||||
checked={formValues.supports_computer_use || false}
|
||||
onChange={(e) => setFormValues({ ...formValues, supports_computer_use: e.target.checked })}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom>推理能力 (supports_reasoning)</Typography>
|
||||
<Select
|
||||
fullWidth
|
||||
value={formValues.supports_reasoning ?? 0}
|
||||
onChange={(e) => setFormValues({ ...formValues, supports_reasoning: parseInt(e.target.value) })}
|
||||
size="small"
|
||||
>
|
||||
{REASONING_OPTIONS.map(r => (
|
||||
<MenuItem key={r.value} value={r.value}>{r.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom>功能标签</Typography>
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
options={TAG_OPTIONS}
|
||||
value={formValues.tags || []}
|
||||
onChange={(e, v) => setFormValues({ ...formValues, tags: v })}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip variant="outlined" label={option} size="small" {...getTagProps({ index })} />
|
||||
))
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
fullWidth
|
||||
placeholder="选择或输入标签"
|
||||
size="small"
|
||||
helperText="如: vision, function-call, reasoning, agent"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeEditModal}>取消</Button>
|
||||
<Button variant="contained" onClick={saveEdit}>保存</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelConfigEditor;
|
||||
@ -18,8 +18,9 @@ import ChannelTableHead from './component/TableHead';
|
||||
import TableToolBar from 'ui-component/TableToolBar';
|
||||
import { API } from 'utils/api';
|
||||
import { ITEMS_PER_PAGE } from 'constants';
|
||||
import { IconRefresh, IconHttpDelete, IconPlus, IconBrandSpeedtest, IconCoinYuan } from '@tabler/icons-react';
|
||||
import { IconRefresh, IconHttpDelete, IconPlus, IconBrandSpeedtest, IconCoinYuan, IconSettings } from '@tabler/icons-react';
|
||||
import EditeModal from './component/EditModal';
|
||||
import ModelConfigEditor from 'components/ModelConfigEditor';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// CHANNEL_OPTIONS,
|
||||
@ -32,6 +33,7 @@ export default function ChannelPage() {
|
||||
const matchUpMd = useMediaQuery(theme.breakpoints.up('sm'));
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const [editChannelId, setEditChannelId] = useState(0);
|
||||
const [openConfigEditor, setOpenConfigEditor] = useState(false);
|
||||
|
||||
const loadChannels = async (startIdx) => {
|
||||
setSearching(true);
|
||||
@ -197,9 +199,18 @@ export default function ChannelPage() {
|
||||
<>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={2.5}>
|
||||
<Typography variant="h4">渠道</Typography>
|
||||
<Button variant="contained" color="primary" startIcon={<IconPlus />} onClick={() => handleOpenModal(0)}>
|
||||
新建渠道
|
||||
</Button>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<IconSettings />}
|
||||
onClick={() => setOpenConfigEditor(true)}
|
||||
>
|
||||
模型配置
|
||||
</Button>
|
||||
<Button variant="contained" color="primary" startIcon={<IconPlus />} onClick={() => handleOpenModal(0)}>
|
||||
新建渠道
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Card>
|
||||
<Box component="form" onSubmit={searchChannels} noValidate sx={{ marginTop: 2 }}>
|
||||
@ -283,6 +294,9 @@ export default function ChannelPage() {
|
||||
/>
|
||||
</Card>
|
||||
<EditeModal open={openModal} onCancel={handleCloseModal} onOk={handleOkModal} channelId={editChannelId} />
|
||||
|
||||
{/* Model Configuration Editor */}
|
||||
<ModelConfigEditor open={openConfigEditor} onClose={() => setOpenConfigEditor(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user