✨ feat(pricing+endpoints+ui): wire custom endpoint mapping end‑to‑end and overhaul visual JSON editor
Backend (Go)
- Include custom endpoints in each model’s SupportedEndpointTypes by parsing Model.Endpoints (JSON) and appending keys alongside native endpoint types.
- Build a global supportedEndpointMap map[string]EndpointInfo{path, method} by:
- Seeding with native defaults.
- Overriding/adding from models.endpoints (accepts string path → default POST, or {path, method}).
- Expose supported_endpoint at the top level of /api/pricing (vendors-like), removing per-model duplication.
- Fix default path for EndpointTypeOpenAIResponse to /v1/responses.
- Keep concurrency/caching for pricing retrieval intact.
Frontend (React)
- Fetch supported_endpoint in useModelPricingData and propagate to PricingPage → ModelDetailSideSheet → ModelEndpoints.
- ModelEndpoints
- Resolve path+method via endpointMap; replace {model} with actual model name.
- Fix mobile visibility; always show path and HTTP method.
- JSONEditor
- Wrap with Form.Slot to inherit form layout; simplify visual styles.
- Use Tabs for “Visual” / “Manual” modes.
- Unify editors: key-value editor now supports nested JSON:
- “+” to convert a primitive into an object and add nested fields.
- Add “Convert to value” for two‑way toggle back from object.
- Stable key rename without reordering rows; new rows append at bottom.
- Use Row/Col grid for clean alignment; region editor uses Form.Slot + grid.
- Editing flows
- EditModelModal / EditPrefillGroupModal use JSONEditor (editorType='object') for endpoint mappings.
- PrefillGroupManagement renders endpoint group items by JSON keys.
Data expectations / compatibility
- models.endpoints should be a JSON object mapping endpoint type → string path or {path, method}. Strings default to POST.
- No schema changes; existing TEXT field continues to store JSON.
QA
- /api/pricing now returns custom endpoint types and global supported_endpoint.
- UI shows both native and custom endpoints; paths/methods render on mobile; nested editing works and preserves order.
This commit is contained in:
32
common/endpoint_defaults.go
Normal file
32
common/endpoint_defaults.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import "one-api/constant"
|
||||||
|
|
||||||
|
// EndpointInfo 描述单个端点的默认请求信息
|
||||||
|
// path: 上游路径
|
||||||
|
// method: HTTP 请求方式,例如 POST/GET
|
||||||
|
// 目前均为 POST,后续可扩展
|
||||||
|
//
|
||||||
|
// json 标签用于直接序列化到 API 输出
|
||||||
|
// 例如:{"path":"/v1/chat/completions","method":"POST"}
|
||||||
|
|
||||||
|
type EndpointInfo struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method
|
||||||
|
var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
|
||||||
|
constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"},
|
||||||
|
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
|
||||||
|
constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
|
||||||
|
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
|
||||||
|
constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"},
|
||||||
|
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
|
||||||
|
func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
|
||||||
|
info, ok := defaultEndpointInfoMap[et]
|
||||||
|
return info, ok
|
||||||
|
}
|
||||||
@@ -42,9 +42,10 @@ func GetPricing(c *gin.Context) {
|
|||||||
"success": true,
|
"success": true,
|
||||||
"data": pricing,
|
"data": pricing,
|
||||||
"vendors": model.GetVendors(),
|
"vendors": model.GetVendors(),
|
||||||
"group_ratio": groupRatio,
|
"group_ratio": groupRatio,
|
||||||
"usable_group": usableGroup,
|
"usable_group": usableGroup,
|
||||||
})
|
"supported_endpoint": model.GetSupportedEndpointMap(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResetModelRatio(c *gin.Context) {
|
func ResetModelRatio(c *gin.Context) {
|
||||||
|
|||||||
139
model/pricing.go
139
model/pricing.go
@@ -1,28 +1,30 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"encoding/json"
|
||||||
"strings"
|
"fmt"
|
||||||
"one-api/common"
|
"strings"
|
||||||
"one-api/constant"
|
|
||||||
"one-api/setting/ratio_setting"
|
"one-api/common"
|
||||||
"one-api/types"
|
"one-api/constant"
|
||||||
"sync"
|
"one-api/setting/ratio_setting"
|
||||||
"time"
|
"one-api/types"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Pricing struct {
|
type Pricing struct {
|
||||||
ModelName string `json:"model_name"`
|
ModelName string `json:"model_name"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Tags string `json:"tags,omitempty"`
|
Tags string `json:"tags,omitempty"`
|
||||||
VendorID int `json:"vendor_id,omitempty"`
|
VendorID int `json:"vendor_id,omitempty"`
|
||||||
QuotaType int `json:"quota_type"`
|
QuotaType int `json:"quota_type"`
|
||||||
ModelRatio float64 `json:"model_ratio"`
|
ModelRatio float64 `json:"model_ratio"`
|
||||||
ModelPrice float64 `json:"model_price"`
|
ModelPrice float64 `json:"model_price"`
|
||||||
OwnerBy string `json:"owner_by"`
|
OwnerBy string `json:"owner_by"`
|
||||||
CompletionRatio float64 `json:"completion_ratio"`
|
CompletionRatio float64 `json:"completion_ratio"`
|
||||||
EnableGroup []string `json:"enable_groups"`
|
EnableGroup []string `json:"enable_groups"`
|
||||||
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
|
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PricingVendor struct {
|
type PricingVendor struct {
|
||||||
@@ -33,10 +35,11 @@ type PricingVendor struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
pricingMap []Pricing
|
pricingMap []Pricing
|
||||||
vendorsList []PricingVendor
|
vendorsList []PricingVendor
|
||||||
lastGetPricingTime time.Time
|
supportedEndpointMap map[string]common.EndpointInfo
|
||||||
updatePricingLock sync.Mutex
|
lastGetPricingTime time.Time
|
||||||
|
updatePricingLock sync.Mutex
|
||||||
|
|
||||||
// 缓存映射:模型名 -> 启用分组 / 计费类型
|
// 缓存映射:模型名 -> 启用分组 / 计费类型
|
||||||
modelEnableGroups = make(map[string][]string)
|
modelEnableGroups = make(map[string][]string)
|
||||||
@@ -176,20 +179,34 @@ func updatePricing() {
|
|||||||
//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
|
//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
|
||||||
modelSupportEndpointsStr := make(map[string][]string)
|
modelSupportEndpointsStr := make(map[string][]string)
|
||||||
|
|
||||||
for _, ability := range enableAbilities {
|
// 先根据已有能力填充原生端点
|
||||||
endpoints, ok := modelSupportEndpointsStr[ability.Model]
|
for _, ability := range enableAbilities {
|
||||||
if !ok {
|
endpoints := modelSupportEndpointsStr[ability.Model]
|
||||||
endpoints = make([]string, 0)
|
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
|
||||||
modelSupportEndpointsStr[ability.Model] = endpoints
|
for _, channelType := range channelTypes {
|
||||||
}
|
if !common.StringsContains(endpoints, string(channelType)) {
|
||||||
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
|
endpoints = append(endpoints, string(channelType))
|
||||||
for _, channelType := range channelTypes {
|
}
|
||||||
if !common.StringsContains(endpoints, string(channelType)) {
|
}
|
||||||
endpoints = append(endpoints, string(channelType))
|
modelSupportEndpointsStr[ability.Model] = endpoints
|
||||||
}
|
}
|
||||||
}
|
|
||||||
modelSupportEndpointsStr[ability.Model] = endpoints
|
// 再补充模型自定义端点
|
||||||
}
|
for modelName, meta := range metaMap {
|
||||||
|
if strings.TrimSpace(meta.Endpoints) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
|
||||||
|
endpoints := modelSupportEndpointsStr[modelName]
|
||||||
|
for k := range raw {
|
||||||
|
if !common.StringsContains(endpoints, k) {
|
||||||
|
endpoints = append(endpoints, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modelSupportEndpointsStr[modelName] = endpoints
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
|
modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
|
||||||
for model, endpoints := range modelSupportEndpointsStr {
|
for model, endpoints := range modelSupportEndpointsStr {
|
||||||
@@ -199,9 +216,48 @@ func updatePricing() {
|
|||||||
supportedEndpoints = append(supportedEndpoints, endpointType)
|
supportedEndpoints = append(supportedEndpoints, endpointType)
|
||||||
}
|
}
|
||||||
modelSupportEndpointTypes[model] = supportedEndpoints
|
modelSupportEndpointTypes[model] = supportedEndpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
pricingMap = make([]Pricing, 0)
|
// 构建全局 supportedEndpointMap(默认 + 自定义覆盖)
|
||||||
|
supportedEndpointMap = make(map[string]common.EndpointInfo)
|
||||||
|
// 1. 默认端点
|
||||||
|
for _, endpoints := range modelSupportEndpointTypes {
|
||||||
|
for _, et := range endpoints {
|
||||||
|
if info, ok := common.GetDefaultEndpointInfo(et); ok {
|
||||||
|
if _, exists := supportedEndpointMap[string(et)]; !exists {
|
||||||
|
supportedEndpointMap[string(et)] = info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. 自定义端点(models 表)覆盖默认
|
||||||
|
for _, meta := range metaMap {
|
||||||
|
if strings.TrimSpace(meta.Endpoints) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
|
||||||
|
for k, v := range raw {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string:
|
||||||
|
supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"}
|
||||||
|
case map[string]interface{}:
|
||||||
|
ep := common.EndpointInfo{Method: "POST"}
|
||||||
|
if p, ok := val["path"].(string); ok {
|
||||||
|
ep.Path = p
|
||||||
|
}
|
||||||
|
if m, ok := val["method"].(string); ok {
|
||||||
|
ep.Method = strings.ToUpper(m)
|
||||||
|
}
|
||||||
|
supportedEndpointMap[k] = ep
|
||||||
|
default:
|
||||||
|
// ignore unsupported types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pricingMap = make([]Pricing, 0)
|
||||||
for model, groups := range modelGroupsMap {
|
for model, groups := range modelGroupsMap {
|
||||||
pricing := Pricing{
|
pricing := Pricing{
|
||||||
ModelName: model,
|
ModelName: model,
|
||||||
@@ -244,3 +300,8 @@ func updatePricing() {
|
|||||||
|
|
||||||
lastGetPricingTime = time.Now()
|
lastGetPricingTime = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSupportedEndpointMap 返回全局端点到路径的映射
|
||||||
|
func GetSupportedEndpointMap() map[string]common.EndpointInfo {
|
||||||
|
return supportedEndpointMap
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Space,
|
|
||||||
Button,
|
Button,
|
||||||
Form,
|
Form,
|
||||||
Card,
|
|
||||||
Typography,
|
Typography,
|
||||||
Banner,
|
Banner,
|
||||||
Row,
|
Tabs,
|
||||||
Col,
|
TabPane,
|
||||||
|
Card,
|
||||||
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Switch,
|
Switch,
|
||||||
Select,
|
TextArea,
|
||||||
Input,
|
Row,
|
||||||
|
Col,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
IconCode,
|
IconCode,
|
||||||
IconEdit,
|
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconDelete,
|
IconDelete,
|
||||||
IconSetting,
|
IconRefresh,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -34,18 +34,17 @@ const JSONEditor = ({
|
|||||||
showClear = true,
|
showClear = true,
|
||||||
template,
|
template,
|
||||||
templateLabel,
|
templateLabel,
|
||||||
editorType = 'keyValue', // keyValue, object, region
|
editorType = 'keyValue',
|
||||||
autosize = true,
|
|
||||||
rules = [],
|
rules = [],
|
||||||
formApi = null,
|
formApi = null,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 初始化JSON数据
|
// 初始化JSON数据
|
||||||
const [jsonData, setJsonData] = useState(() => {
|
const [jsonData, setJsonData] = useState(() => {
|
||||||
// 初始化时解析JSON数据
|
// 初始化时解析JSON数据
|
||||||
if (value && value.trim()) {
|
if (typeof value === 'string' && value.trim()) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(value);
|
||||||
return parsed;
|
return parsed;
|
||||||
@@ -53,13 +52,16 @@ const JSONEditor = ({
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 根据键数量决定默认编辑模式
|
// 根据键数量决定默认编辑模式
|
||||||
const [editMode, setEditMode] = useState(() => {
|
const [editMode, setEditMode] = useState(() => {
|
||||||
// 如果初始JSON数据的键数量大于10个,则默认使用手动模式
|
// 如果初始JSON数据的键数量大于10个,则默认使用手动模式
|
||||||
if (value && value.trim()) {
|
if (typeof value === 'string' && value.trim()) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(value);
|
||||||
const keyCount = Object.keys(parsed).length;
|
const keyCount = Object.keys(parsed).length;
|
||||||
@@ -76,7 +78,12 @@ const JSONEditor = ({
|
|||||||
// 数据同步 - 当value变化时总是更新jsonData(如果JSON有效)
|
// 数据同步 - 当value变化时总是更新jsonData(如果JSON有效)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const parsed = value && value.trim() ? JSON.parse(value) : {};
|
let parsed = {};
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
parsed = JSON.parse(value);
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
parsed = value;
|
||||||
|
}
|
||||||
setJsonData(parsed);
|
setJsonData(parsed);
|
||||||
setJsonError('');
|
setJsonError('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -86,18 +93,17 @@ const JSONEditor = ({
|
|||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
|
||||||
// 处理可视化编辑的数据变化
|
// 处理可视化编辑的数据变化
|
||||||
const handleVisualChange = useCallback((newData) => {
|
const handleVisualChange = useCallback((newData) => {
|
||||||
setJsonData(newData);
|
setJsonData(newData);
|
||||||
setJsonError('');
|
setJsonError('');
|
||||||
const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
|
const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
|
||||||
|
|
||||||
// 通过formApi设置值(如果提供的话)
|
// 通过formApi设置值(如果提供的话)
|
||||||
if (formApi && field) {
|
if (formApi && field) {
|
||||||
formApi.setValue(field, jsonString);
|
formApi.setValue(field, jsonString);
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange?.(jsonString);
|
onChange?.(jsonString);
|
||||||
}, [onChange, formApi, field]);
|
}, [onChange, formApi, field]);
|
||||||
|
|
||||||
@@ -127,7 +133,12 @@ const JSONEditor = ({
|
|||||||
} else {
|
} else {
|
||||||
// 从手动模式切换到可视化模式,需要验证JSON
|
// 从手动模式切换到可视化模式,需要验证JSON
|
||||||
try {
|
try {
|
||||||
const parsed = value && value.trim() ? JSON.parse(value) : {};
|
let parsed = {};
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
parsed = JSON.parse(value);
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
parsed = value;
|
||||||
|
}
|
||||||
setJsonData(parsed);
|
setJsonData(parsed);
|
||||||
setJsonError('');
|
setJsonError('');
|
||||||
setEditMode('visual');
|
setEditMode('visual');
|
||||||
@@ -143,11 +154,11 @@ const JSONEditor = ({
|
|||||||
const addKeyValue = useCallback(() => {
|
const addKeyValue = useCallback(() => {
|
||||||
const newData = { ...jsonData };
|
const newData = { ...jsonData };
|
||||||
const keys = Object.keys(newData);
|
const keys = Object.keys(newData);
|
||||||
let newKey = 'key';
|
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
|
let newKey = `field_${counter}`;
|
||||||
while (newData.hasOwnProperty(newKey)) {
|
while (newData.hasOwnProperty(newKey)) {
|
||||||
newKey = `key${counter}`;
|
counter += 1;
|
||||||
counter++;
|
newKey = `field_${counter}`;
|
||||||
}
|
}
|
||||||
newData[newKey] = '';
|
newData[newKey] = '';
|
||||||
handleVisualChange(newData);
|
handleVisualChange(newData);
|
||||||
@@ -162,11 +173,15 @@ const JSONEditor = ({
|
|||||||
|
|
||||||
// 更新键名
|
// 更新键名
|
||||||
const updateKey = useCallback((oldKey, newKey) => {
|
const updateKey = useCallback((oldKey, newKey) => {
|
||||||
if (oldKey === newKey) return;
|
if (oldKey === newKey || !newKey) return;
|
||||||
const newData = { ...jsonData };
|
const newData = {};
|
||||||
const value = newData[oldKey];
|
Object.entries(jsonData).forEach(([k, v]) => {
|
||||||
delete newData[oldKey];
|
if (k === oldKey) {
|
||||||
newData[newKey] = value;
|
newData[newKey] = v;
|
||||||
|
} else {
|
||||||
|
newData[k] = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
handleVisualChange(newData);
|
handleVisualChange(newData);
|
||||||
}, [jsonData, handleVisualChange]);
|
}, [jsonData, handleVisualChange]);
|
||||||
|
|
||||||
@@ -181,20 +196,20 @@ const JSONEditor = ({
|
|||||||
const fillTemplate = useCallback(() => {
|
const fillTemplate = useCallback(() => {
|
||||||
if (template) {
|
if (template) {
|
||||||
const templateString = JSON.stringify(template, null, 2);
|
const templateString = JSON.stringify(template, null, 2);
|
||||||
|
|
||||||
// 通过formApi设置值(如果提供的话)
|
// 通过formApi设置值(如果提供的话)
|
||||||
if (formApi && field) {
|
if (formApi && field) {
|
||||||
formApi.setValue(field, templateString);
|
formApi.setValue(field, templateString);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无论哪种模式都要更新值
|
// 无论哪种模式都要更新值
|
||||||
onChange?.(templateString);
|
onChange?.(templateString);
|
||||||
|
|
||||||
// 如果是可视化模式,同时更新jsonData
|
// 如果是可视化模式,同时更新jsonData
|
||||||
if (editMode === 'visual') {
|
if (editMode === 'visual') {
|
||||||
setJsonData(template);
|
setJsonData(template);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除错误状态
|
// 清除错误状态
|
||||||
setJsonError('');
|
setJsonError('');
|
||||||
}
|
}
|
||||||
@@ -215,69 +230,47 @@ const JSONEditor = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const entries = Object.entries(jsonData);
|
const entries = Object.entries(jsonData);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{entries.length === 0 && (
|
{entries.length === 0 && (
|
||||||
<div className="text-center py-6 px-4">
|
<div className="text-center py-6 px-4">
|
||||||
<div className="text-gray-400 mb-2">
|
|
||||||
<IconCode size={32} />
|
|
||||||
</div>
|
|
||||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
<Text type="tertiary" className="text-gray-500 text-sm">
|
||||||
{t('暂无数据,点击下方按钮添加键值对')}
|
{t('暂无数据,点击下方按钮添加键值对')}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{entries.map(([key, value], index) => (
|
{entries.map(([key, value], index) => (
|
||||||
<Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
|
<Row key={index} gutter={8} align="middle">
|
||||||
<Row gutter={12} align="middle">
|
<Col span={6}>
|
||||||
<Col span={10}>
|
<Input
|
||||||
<div className="space-y-1">
|
placeholder={t('键名')}
|
||||||
<Text type="tertiary" size="small">{t('键名')}</Text>
|
value={key}
|
||||||
<Input
|
onChange={(newKey) => updateKey(key, newKey)}
|
||||||
placeholder={t('键名')}
|
/>
|
||||||
value={key}
|
</Col>
|
||||||
onChange={(newKey) => updateKey(key, newKey)}
|
<Col span={16}>
|
||||||
size="small"
|
{renderValueInput(key, value)}
|
||||||
/>
|
</Col>
|
||||||
</div>
|
<Col span={2}>
|
||||||
</Col>
|
<Button
|
||||||
<Col span={11}>
|
icon={<IconDelete />}
|
||||||
<div className="space-y-1">
|
type="danger"
|
||||||
<Text type="tertiary" size="small">{t('值')}</Text>
|
theme="borderless"
|
||||||
<Input
|
onClick={() => removeKeyValue(key)}
|
||||||
placeholder={t('值')}
|
style={{ width: '100%' }}
|
||||||
value={value}
|
/>
|
||||||
onChange={(newValue) => updateValue(key, newValue)}
|
</Col>
|
||||||
size="small"
|
</Row>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col span={3}>
|
|
||||||
<div className="flex justify-center pt-4">
|
|
||||||
<Button
|
|
||||||
icon={<IconDelete />}
|
|
||||||
type="danger"
|
|
||||||
theme="borderless"
|
|
||||||
size="small"
|
|
||||||
onClick={() => removeKeyValue(key)}
|
|
||||||
className="hover:bg-red-50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="flex justify-center pt-1">
|
<div className="mt-2 flex justify-center">
|
||||||
<Button
|
<Button
|
||||||
icon={<IconPlus />}
|
icon={<IconPlus />}
|
||||||
onClick={addKeyValue}
|
|
||||||
size="small"
|
|
||||||
theme="solid"
|
|
||||||
type="primary"
|
type="primary"
|
||||||
className="shadow-sm hover:shadow-md transition-shadow px-4"
|
theme="outline"
|
||||||
|
onClick={addKeyValue}
|
||||||
>
|
>
|
||||||
{t('添加键值对')}
|
{t('添加键值对')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -286,100 +279,61 @@ const JSONEditor = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染对象编辑器(用于复杂JSON)
|
// 添加嵌套对象
|
||||||
const renderObjectEditor = () => {
|
const flattenObject = useCallback((parentKey) => {
|
||||||
const entries = Object.entries(jsonData);
|
const newData = { ...jsonData };
|
||||||
|
let primitive = '';
|
||||||
return (
|
const obj = newData[parentKey];
|
||||||
<div className="space-y-1">
|
if (obj && typeof obj === 'object') {
|
||||||
{entries.length === 0 && (
|
const firstKey = Object.keys(obj)[0];
|
||||||
<div className="text-center py-6 px-4">
|
if (firstKey !== undefined) {
|
||||||
<div className="text-gray-400 mb-2">
|
const firstVal = obj[firstKey];
|
||||||
<IconSetting size={32} />
|
if (typeof firstVal !== 'object') primitive = firstVal;
|
||||||
</div>
|
}
|
||||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
}
|
||||||
{t('暂无参数,点击下方按钮添加请求参数')}
|
newData[parentKey] = primitive;
|
||||||
</Text>
|
handleVisualChange(newData);
|
||||||
</div>
|
}, [jsonData, handleVisualChange]);
|
||||||
)}
|
|
||||||
|
|
||||||
{entries.map(([key, value], index) => (
|
|
||||||
<Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
|
|
||||||
<Row gutter={12} align="middle">
|
|
||||||
<Col span={8}>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Text type="tertiary" size="small">{t('参数名')}</Text>
|
|
||||||
<Input
|
|
||||||
placeholder={t('参数名')}
|
|
||||||
value={key}
|
|
||||||
onChange={(newKey) => updateKey(key, newKey)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col span={13}>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Text type="tertiary" size="small">{t('参数值')} ({typeof value})</Text>
|
|
||||||
{renderValueInput(key, value)}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col span={3}>
|
|
||||||
<div className="flex justify-center pt-4">
|
|
||||||
<Button
|
|
||||||
icon={<IconDelete />}
|
|
||||||
type="danger"
|
|
||||||
theme="borderless"
|
|
||||||
size="small"
|
|
||||||
onClick={() => removeKeyValue(key)}
|
|
||||||
className="hover:bg-red-50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="flex justify-center pt-1">
|
|
||||||
<Button
|
|
||||||
icon={<IconPlus />}
|
|
||||||
onClick={addKeyValue}
|
|
||||||
size="small"
|
|
||||||
theme="solid"
|
|
||||||
type="primary"
|
|
||||||
className="shadow-sm hover:shadow-md transition-shadow px-4"
|
|
||||||
>
|
|
||||||
{t('添加参数')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染参数值输入控件
|
const addNestedObject = useCallback((parentKey) => {
|
||||||
|
const newData = { ...jsonData };
|
||||||
|
if (typeof newData[parentKey] !== 'object' || newData[parentKey] === null) {
|
||||||
|
newData[parentKey] = {};
|
||||||
|
}
|
||||||
|
const existingKeys = Object.keys(newData[parentKey]);
|
||||||
|
let counter = 1;
|
||||||
|
let newKey = `field_${counter}`;
|
||||||
|
while (newData[parentKey].hasOwnProperty(newKey)) {
|
||||||
|
counter += 1;
|
||||||
|
newKey = `field_${counter}`;
|
||||||
|
}
|
||||||
|
newData[parentKey][newKey] = '';
|
||||||
|
handleVisualChange(newData);
|
||||||
|
}, [jsonData, handleVisualChange]);
|
||||||
|
|
||||||
|
// 渲染参数值输入控件(支持嵌套)
|
||||||
const renderValueInput = (key, value) => {
|
const renderValueInput = (key, value) => {
|
||||||
const valueType = typeof value;
|
const valueType = typeof value;
|
||||||
|
|
||||||
if (valueType === 'boolean') {
|
if (valueType === 'boolean') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Switch
|
<Switch
|
||||||
checked={value}
|
checked={value}
|
||||||
onChange={(newValue) => updateValue(key, newValue)}
|
onChange={(newValue) => updateValue(key, newValue)}
|
||||||
size="small"
|
|
||||||
/>
|
/>
|
||||||
<Text type="tertiary" size="small" className="ml-2">
|
<Text type="tertiary" className="ml-2">
|
||||||
{value ? t('true') : t('false')}
|
{value ? t('true') : t('false')}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (valueType === 'number') {
|
if (valueType === 'number') {
|
||||||
return (
|
return (
|
||||||
<InputNumber
|
<InputNumber
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(newValue) => updateValue(key, newValue)}
|
onChange={(newValue) => updateValue(key, newValue)}
|
||||||
size="small"
|
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
step={key === 'temperature' ? 0.1 : 1}
|
step={key === 'temperature' ? 0.1 : 1}
|
||||||
precision={key === 'temperature' ? 2 : 0}
|
precision={key === 'temperature' ? 2 : 0}
|
||||||
@@ -387,25 +341,137 @@ const JSONEditor = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 字符串类型或其他类型
|
if (valueType === 'object' && value !== null) {
|
||||||
|
// 渲染嵌套对象
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
return (
|
||||||
|
<Card className="!rounded-2xl">
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<Text type="tertiary" className="text-gray-500 text-xs">
|
||||||
|
{t('空对象,点击下方加号添加字段')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.map(([nestedKey, nestedValue], index) => (
|
||||||
|
<Row key={index} gutter={4} align="middle" className="mb-1">
|
||||||
|
<Col span={8}>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder={t('键名')}
|
||||||
|
value={nestedKey}
|
||||||
|
onChange={(newKey) => {
|
||||||
|
const newData = { ...jsonData };
|
||||||
|
const oldValue = newData[key][nestedKey];
|
||||||
|
delete newData[key][nestedKey];
|
||||||
|
newData[key][newKey] = oldValue;
|
||||||
|
handleVisualChange(newData);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={14}>
|
||||||
|
{typeof nestedValue === 'object' && nestedValue !== null ? (
|
||||||
|
<TextArea
|
||||||
|
size="small"
|
||||||
|
rows={2}
|
||||||
|
value={JSON.stringify(nestedValue, null, 2)}
|
||||||
|
onChange={(txt) => {
|
||||||
|
try {
|
||||||
|
const obj = txt.trim() ? JSON.parse(txt) : {};
|
||||||
|
const newData = { ...jsonData };
|
||||||
|
newData[key][nestedKey] = obj;
|
||||||
|
handleVisualChange(newData);
|
||||||
|
} catch {
|
||||||
|
// ignore parse error
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder={t('值')}
|
||||||
|
value={String(nestedValue)}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
const newData = { ...jsonData };
|
||||||
|
let convertedValue = newValue;
|
||||||
|
if (newValue === 'true') convertedValue = true;
|
||||||
|
else if (newValue === 'false') convertedValue = false;
|
||||||
|
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
|
||||||
|
convertedValue = Number(newValue);
|
||||||
|
}
|
||||||
|
newData[key][nestedKey] = convertedValue;
|
||||||
|
handleVisualChange(newData);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
<Col span={2}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
type="danger"
|
||||||
|
theme="borderless"
|
||||||
|
onClick={() => {
|
||||||
|
const newData = { ...jsonData };
|
||||||
|
delete newData[key][nestedKey];
|
||||||
|
handleVisualChange(newData);
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex justify-center mt-1 gap-2">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<IconPlus />}
|
||||||
|
type="tertiary"
|
||||||
|
onClick={() => addNestedObject(key)}
|
||||||
|
>
|
||||||
|
{t('添加字段')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<IconRefresh />}
|
||||||
|
type="tertiary"
|
||||||
|
onClick={() => flattenObject(key)}
|
||||||
|
>
|
||||||
|
{t('转换为值')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字符串或其他原始类型
|
||||||
return (
|
return (
|
||||||
<Input
|
<div className="flex items-center gap-1">
|
||||||
placeholder={t('参数值')}
|
<Input
|
||||||
value={String(value)}
|
placeholder={t('参数值')}
|
||||||
onChange={(newValue) => {
|
value={String(value)}
|
||||||
// 尝试转换为适当的类型
|
onChange={(newValue) => {
|
||||||
let convertedValue = newValue;
|
let convertedValue = newValue;
|
||||||
if (newValue === 'true') convertedValue = true;
|
if (newValue === 'true') convertedValue = true;
|
||||||
else if (newValue === 'false') convertedValue = false;
|
else if (newValue === 'false') convertedValue = false;
|
||||||
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
|
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
|
||||||
convertedValue = Number(newValue);
|
convertedValue = Number(newValue);
|
||||||
}
|
}
|
||||||
|
updateValue(key, convertedValue);
|
||||||
updateValue(key, convertedValue);
|
}}
|
||||||
}}
|
/>
|
||||||
size="small"
|
<Button
|
||||||
/>
|
icon={<IconPlus />}
|
||||||
|
type="tertiary"
|
||||||
|
onClick={() => {
|
||||||
|
// 将当前值转换为对象
|
||||||
|
const newData = { ...jsonData };
|
||||||
|
newData[key] = { '1': value };
|
||||||
|
handleVisualChange(newData);
|
||||||
|
}}
|
||||||
|
title={t('转换为对象')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -414,79 +480,61 @@ const JSONEditor = ({
|
|||||||
const entries = Object.entries(jsonData);
|
const entries = Object.entries(jsonData);
|
||||||
const defaultEntry = entries.find(([key]) => key === 'default');
|
const defaultEntry = entries.find(([key]) => key === 'default');
|
||||||
const modelEntries = entries.filter(([key]) => key !== 'default');
|
const modelEntries = entries.filter(([key]) => key !== 'default');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
{/* 默认区域 */}
|
{/* 默认区域 */}
|
||||||
<Card className="!p-2 !border-blue-200 !bg-blue-50">
|
<Form.Slot label={t('默认区域')}>
|
||||||
<div className="flex items-center mb-1">
|
|
||||||
<Text strong size="small" className="text-blue-700">{t('默认区域')}</Text>
|
|
||||||
</div>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('默认区域,如: us-central1')}
|
placeholder={t('默认区域,如: us-central1')}
|
||||||
value={defaultEntry ? defaultEntry[1] : ''}
|
value={defaultEntry ? defaultEntry[1] : ''}
|
||||||
onChange={(value) => updateValue('default', value)}
|
onChange={(value) => updateValue('default', value)}
|
||||||
size="small"
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Form.Slot>
|
||||||
|
|
||||||
{/* 模型专用区域 */}
|
{/* 模型专用区域 */}
|
||||||
<div className="space-y-1">
|
<Form.Slot label={t('模型专用区域')}>
|
||||||
<Text strong size="small">{t('模型专用区域')}</Text>
|
<div>
|
||||||
{modelEntries.map(([modelName, region], index) => (
|
{modelEntries.map(([modelName, region], index) => (
|
||||||
<Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
|
<Row key={index} gutter={8} align="middle" className="mb-2">
|
||||||
<Row gutter={12} align="middle">
|
|
||||||
<Col span={10}>
|
<Col span={10}>
|
||||||
<div className="space-y-1">
|
<Input
|
||||||
<Text type="tertiary" size="small">{t('模型名称')}</Text>
|
placeholder={t('模型名称')}
|
||||||
<Input
|
value={modelName}
|
||||||
placeholder={t('模型名称')}
|
onChange={(newKey) => updateKey(modelName, newKey)}
|
||||||
value={modelName}
|
/>
|
||||||
onChange={(newKey) => updateKey(modelName, newKey)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={11}>
|
<Col span={12}>
|
||||||
<div className="space-y-1">
|
<Input
|
||||||
<Text type="tertiary" size="small">{t('区域')}</Text>
|
placeholder={t('区域')}
|
||||||
<Input
|
value={region}
|
||||||
placeholder={t('区域')}
|
onChange={(newValue) => updateValue(modelName, newValue)}
|
||||||
value={region}
|
/>
|
||||||
onChange={(newValue) => updateValue(modelName, newValue)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={3}>
|
<Col span={2}>
|
||||||
<div className="flex justify-center pt-4">
|
<Button
|
||||||
<Button
|
icon={<IconDelete />}
|
||||||
icon={<IconDelete />}
|
type="danger"
|
||||||
type="danger"
|
theme="borderless"
|
||||||
theme="borderless"
|
onClick={() => removeKeyValue(modelName)}
|
||||||
size="small"
|
style={{ width: '100%' }}
|
||||||
onClick={() => removeKeyValue(modelName)}
|
/>
|
||||||
className="hover:bg-red-50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
))}
|
||||||
))}
|
|
||||||
|
<div className="mt-2 flex justify-center">
|
||||||
<div className="flex justify-center pt-1">
|
<Button
|
||||||
<Button
|
icon={<IconPlus />}
|
||||||
icon={<IconPlus />}
|
onClick={addKeyValue}
|
||||||
onClick={addKeyValue}
|
type="primary"
|
||||||
size="small"
|
theme="outline"
|
||||||
theme="solid"
|
>
|
||||||
type="primary"
|
{t('添加模型区域')}
|
||||||
className="shadow-sm hover:shadow-md transition-shadow px-4"
|
</Button>
|
||||||
>
|
</div>
|
||||||
{t('添加模型区域')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Form.Slot>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -497,7 +545,6 @@ const JSONEditor = ({
|
|||||||
case 'region':
|
case 'region':
|
||||||
return renderRegionEditor();
|
return renderRegionEditor();
|
||||||
case 'object':
|
case 'object':
|
||||||
return renderObjectEditor();
|
|
||||||
case 'keyValue':
|
case 'keyValue':
|
||||||
default:
|
default:
|
||||||
return renderKeyValueEditor();
|
return renderKeyValueEditor();
|
||||||
@@ -507,115 +554,92 @@ const JSONEditor = ({
|
|||||||
const hasJsonError = jsonError && jsonError.trim() !== '';
|
const hasJsonError = jsonError && jsonError.trim() !== '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<Form.Slot label={label}>
|
||||||
{/* Label统一显示在上方 */}
|
<Card
|
||||||
{label && (
|
header={
|
||||||
<div className="flex items-center">
|
<div className="flex justify-between items-center">
|
||||||
<Text className="text-sm font-medium text-gray-900">{label}</Text>
|
<Tabs
|
||||||
</div>
|
type="slash"
|
||||||
)}
|
activeKey={editMode}
|
||||||
|
onChange={(key) => {
|
||||||
{/* 编辑模式切换 */}
|
if (key === 'manual' && editMode === 'visual') {
|
||||||
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
|
setEditMode('manual');
|
||||||
<div className="flex items-center gap-2">
|
} else if (key === 'visual' && editMode === 'manual') {
|
||||||
{editMode === 'visual' && (
|
toggleEditMode();
|
||||||
<Text type="tertiary" size="small" className="bg-blue-100 text-blue-700 px-2 py-0.5 rounded text-xs">
|
}
|
||||||
{t('可视化模式')}
|
}}
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{editMode === 'manual' && (
|
|
||||||
<Text type="tertiary" size="small" className="bg-green-100 text-green-700 px-2 py-0.5 rounded text-xs">
|
|
||||||
{t('手动编辑模式')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{template && templateLabel && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="tertiary"
|
|
||||||
onClick={fillTemplate}
|
|
||||||
className="!text-semi-color-primary hover:bg-blue-50 text-xs"
|
|
||||||
>
|
>
|
||||||
{templateLabel}
|
<TabPane tab={t('可视化')} itemKey="visual" />
|
||||||
</Button>
|
<TabPane tab={t('手动编辑')} itemKey="manual" />
|
||||||
)}
|
</Tabs>
|
||||||
<Space size="tight">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type={editMode === 'visual' ? 'primary' : 'tertiary'}
|
|
||||||
icon={<IconEdit />}
|
|
||||||
onClick={toggleEditMode}
|
|
||||||
disabled={editMode === 'manual' && hasJsonError}
|
|
||||||
className={editMode === 'visual' ? 'shadow-sm' : ''}
|
|
||||||
>
|
|
||||||
{t('可视化')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type={editMode === 'manual' ? 'primary' : 'tertiary'}
|
|
||||||
icon={<IconCode />}
|
|
||||||
onClick={toggleEditMode}
|
|
||||||
className={editMode === 'manual' ? 'shadow-sm' : ''}
|
|
||||||
>
|
|
||||||
{t('手动编辑')}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* JSON错误提示 */}
|
{template && templateLabel && (
|
||||||
{hasJsonError && (
|
<Button
|
||||||
<Banner
|
type="tertiary"
|
||||||
type="danger"
|
onClick={fillTemplate}
|
||||||
description={`JSON 格式错误: ${jsonError}`}
|
size="small"
|
||||||
className="!rounded-md text-sm"
|
>
|
||||||
/>
|
{templateLabel}
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
{/* 编辑器内容 */}
|
</div>
|
||||||
{editMode === 'visual' ? (
|
}
|
||||||
<div>
|
headerStyle={{ padding: '12px 16px' }}
|
||||||
<Card className="!p-3 !border-gray-200 !shadow-sm !rounded-md bg-white">
|
bodyStyle={{ padding: '16px' }}
|
||||||
{renderVisualEditor()}
|
className="!rounded-2xl"
|
||||||
</Card>
|
>
|
||||||
{/* 可视化模式下的额外文本显示在下方 */}
|
{/* JSON错误提示 */}
|
||||||
{extraText && (
|
{hasJsonError && (
|
||||||
<div className="text-xs text-gray-600 mt-0.5">
|
<Banner
|
||||||
{extraText}
|
type="danger"
|
||||||
</div>
|
description={`JSON 格式错误: ${jsonError}`}
|
||||||
)}
|
className="mb-3"
|
||||||
{/* 隐藏的Form字段用于验证和数据绑定 */}
|
|
||||||
<Form.Input
|
|
||||||
field={field}
|
|
||||||
value={value}
|
|
||||||
rules={rules}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
noLabel={true}
|
|
||||||
{...props}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
) : (
|
|
||||||
<Form.TextArea
|
|
||||||
field={field}
|
|
||||||
placeholder={placeholder}
|
|
||||||
value={value}
|
|
||||||
onChange={handleManualChange}
|
|
||||||
showClear={showClear}
|
|
||||||
rows={Math.max(8, value ? value.split('\n').length : 8)}
|
|
||||||
rules={rules}
|
|
||||||
noLabel={true}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 额外文本在手动编辑模式下显示 */}
|
{/* 编辑器内容 */}
|
||||||
{extraText && editMode === 'manual' && (
|
{editMode === 'visual' ? (
|
||||||
<div className="text-xs text-gray-600">
|
<div>
|
||||||
{extraText}
|
{renderVisualEditor()}
|
||||||
</div>
|
{/* 隐藏的Form字段用于验证和数据绑定 */}
|
||||||
)}
|
<Form.Input
|
||||||
</div>
|
field={field}
|
||||||
|
value={value}
|
||||||
|
rules={rules}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
noLabel={true}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<TextArea
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={handleManualChange}
|
||||||
|
showClear={showClear}
|
||||||
|
rows={Math.max(8, value ? value.split('\n').length : 8)}
|
||||||
|
/>
|
||||||
|
{/* 隐藏的Form字段用于验证和数据绑定 */}
|
||||||
|
<Form.Input
|
||||||
|
field={field}
|
||||||
|
value={value}
|
||||||
|
rules={rules}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
noLabel={true}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 额外文本显示在卡片底部 */}
|
||||||
|
{extraText && (
|
||||||
|
<div className="text-gray-600 mt-3 pt-3">
|
||||||
|
{extraText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Form.Slot>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const PricingPage = () => {
|
|||||||
displayPrice={pricingData.displayPrice}
|
displayPrice={pricingData.displayPrice}
|
||||||
showRatio={allProps.showRatio}
|
showRatio={allProps.showRatio}
|
||||||
vendorsMap={pricingData.vendorsMap}
|
vendorsMap={pricingData.vendorsMap}
|
||||||
|
endpointMap={pricingData.endpointMap}
|
||||||
t={pricingData.t}
|
t={pricingData.t}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const ModelDetailSideSheet = ({
|
|||||||
showRatio,
|
showRatio,
|
||||||
usableGroup,
|
usableGroup,
|
||||||
vendorsMap,
|
vendorsMap,
|
||||||
|
endpointMap,
|
||||||
t,
|
t,
|
||||||
}) => {
|
}) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -82,7 +83,7 @@ const ModelDetailSideSheet = ({
|
|||||||
{modelData && (
|
{modelData && (
|
||||||
<>
|
<>
|
||||||
<ModelBasicInfo modelData={modelData} vendorsMap={vendorsMap} t={t} />
|
<ModelBasicInfo modelData={modelData} vendorsMap={vendorsMap} t={t} />
|
||||||
<ModelEndpoints modelData={modelData} t={t} />
|
<ModelEndpoints modelData={modelData} endpointMap={endpointMap} t={t} />
|
||||||
<ModelPricingTable
|
<ModelPricingTable
|
||||||
modelData={modelData}
|
modelData={modelData}
|
||||||
selectedGroup={selectedGroup}
|
selectedGroup={selectedGroup}
|
||||||
|
|||||||
@@ -23,31 +23,45 @@ import { IconLink } from '@douyinfe/semi-icons';
|
|||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
const ModelEndpoints = ({ modelData, t }) => {
|
const ModelEndpoints = ({ modelData, endpointMap = {}, t }) => {
|
||||||
const renderAPIEndpoints = () => {
|
const renderAPIEndpoints = () => {
|
||||||
const endpoints = [];
|
if (!modelData) return null;
|
||||||
|
|
||||||
if (modelData?.supported_endpoint_types) {
|
const mapping = endpointMap;
|
||||||
modelData.supported_endpoint_types.forEach(endpoint => {
|
const types = modelData.supported_endpoint_types || [];
|
||||||
endpoints.push({ name: endpoint, type: endpoint });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return endpoints.map((endpoint, index) => (
|
return types.map(type => {
|
||||||
<div
|
const info = mapping[type] || {};
|
||||||
key={index}
|
let path = info.path || '';
|
||||||
className="flex justify-between border-b border-dashed last:border-0 py-2 last:pb-0"
|
// 如果路径中包含 {model} 占位符,替换为真实模型名称
|
||||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
if (path.includes('{model}')) {
|
||||||
>
|
const modelName = modelData.model_name || modelData.modelName || '';
|
||||||
<span className="flex items-center pr-5">
|
path = path.replaceAll('{model}', modelName);
|
||||||
<Badge dot type="success" className="mr-2" />
|
}
|
||||||
{endpoint.name}:
|
const method = info.method || 'POST';
|
||||||
<span className="text-gray-500 hidden md:inline">https://api.newapi.pro</span>
|
return (
|
||||||
/v1/chat/completions
|
<div
|
||||||
</span>
|
key={type}
|
||||||
<span className="text-gray-500 text-xs hidden md:inline">POST</span>
|
className="flex justify-between border-b border-dashed last:border-0 py-2 last:pb-0"
|
||||||
</div>
|
style={{ borderColor: 'var(--semi-color-border)' }}
|
||||||
));
|
>
|
||||||
|
<span className="flex items-center pr-5">
|
||||||
|
<Badge dot type="success" className="mr-2" />
|
||||||
|
{type}{path && ':'}
|
||||||
|
{path && (
|
||||||
|
<span className="text-gray-500 md:ml-1 break-all">
|
||||||
|
{path}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{path && (
|
||||||
|
<span className="text-gray-500 text-xs md:ml-1">
|
||||||
|
{method}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
|
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||||
import {
|
import {
|
||||||
SideSheet,
|
SideSheet,
|
||||||
Form,
|
Form,
|
||||||
@@ -109,7 +110,7 @@ const EditModelModal = (props) => {
|
|||||||
vendor_id: undefined,
|
vendor_id: undefined,
|
||||||
vendor: '',
|
vendor: '',
|
||||||
vendor_icon: '',
|
vendor_icon: '',
|
||||||
endpoints: [],
|
endpoints: '',
|
||||||
name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
|
name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
|
||||||
status: true,
|
status: true,
|
||||||
});
|
});
|
||||||
@@ -132,15 +133,9 @@ const EditModelModal = (props) => {
|
|||||||
} else {
|
} else {
|
||||||
data.tags = [];
|
data.tags = [];
|
||||||
}
|
}
|
||||||
// 处理endpoints
|
// endpoints 保持原始 JSON 字符串,若为空设为空串
|
||||||
if (data.endpoints) {
|
if (!data.endpoints) {
|
||||||
try {
|
data.endpoints = '';
|
||||||
data.endpoints = JSON.parse(data.endpoints);
|
|
||||||
} catch (e) {
|
|
||||||
data.endpoints = [];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
data.endpoints = [];
|
|
||||||
}
|
}
|
||||||
// 处理status,将数字转为布尔值
|
// 处理status,将数字转为布尔值
|
||||||
data.status = data.status === 1;
|
data.status = data.status === 1;
|
||||||
@@ -188,7 +183,7 @@ const EditModelModal = (props) => {
|
|||||||
const submitData = {
|
const submitData = {
|
||||||
...values,
|
...values,
|
||||||
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
|
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
|
||||||
endpoints: JSON.stringify(values.endpoints || []),
|
endpoints: values.endpoints || '',
|
||||||
status: values.status ? 1 : 0,
|
status: values.status ? 1 : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -382,36 +377,15 @@ const EditModelModal = (props) => {
|
|||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Form.TagInput
|
<JSONEditor
|
||||||
field='endpoints'
|
field='endpoints'
|
||||||
label={t('支持端点')}
|
label={t('端点映射')}
|
||||||
placeholder={t('输入端点名称,按回车添加')}
|
placeholder={'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
|
||||||
addOnBlur
|
value={values.endpoints}
|
||||||
showClear
|
onChange={(val) => formApiRef.current?.setValue('endpoints', val)}
|
||||||
style={{ width: '100%' }}
|
formApi={formApiRef.current}
|
||||||
{...(endpointGroups.length > 0 && {
|
editorType='object'
|
||||||
extraText: (
|
extraText={t('留空则使用默认端点;支持 {path, method}')}
|
||||||
<Space wrap>
|
|
||||||
{endpointGroups.map(group => (
|
|
||||||
<Button
|
|
||||||
key={group.id}
|
|
||||||
size='small'
|
|
||||||
type='primary'
|
|
||||||
onClick={() => {
|
|
||||||
if (formApiRef.current) {
|
|
||||||
const currentEndpoints = formApiRef.current.getValue('endpoints') || [];
|
|
||||||
const newEndpoints = [...currentEndpoints, ...(group.items || [])];
|
|
||||||
const uniqueEndpoints = [...new Set(newEndpoints)];
|
|
||||||
formApiRef.current.setValue('endpoints', uniqueEndpoints);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{group.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||||
import {
|
import {
|
||||||
SideSheet,
|
SideSheet,
|
||||||
Button,
|
Button,
|
||||||
@@ -49,6 +50,13 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
|
|||||||
const formRef = useRef(null);
|
const formRef = useRef(null);
|
||||||
const isEdit = editingGroup && editingGroup.id !== undefined;
|
const isEdit = editingGroup && editingGroup.id !== undefined;
|
||||||
|
|
||||||
|
const [selectedType, setSelectedType] = useState(editingGroup?.type || 'tag');
|
||||||
|
|
||||||
|
// 当外部传入的编辑组类型变化时同步 selectedType
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedType(editingGroup?.type || 'tag');
|
||||||
|
}, [editingGroup?.type]);
|
||||||
|
|
||||||
const typeOptions = [
|
const typeOptions = [
|
||||||
{ label: t('模型组'), value: 'model' },
|
{ label: t('模型组'), value: 'model' },
|
||||||
{ label: t('标签组'), value: 'tag' },
|
{ label: t('标签组'), value: 'tag' },
|
||||||
@@ -61,8 +69,12 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
|
|||||||
try {
|
try {
|
||||||
const submitData = {
|
const submitData = {
|
||||||
...values,
|
...values,
|
||||||
items: Array.isArray(values.items) ? values.items : [],
|
|
||||||
};
|
};
|
||||||
|
if (values.type === 'endpoint') {
|
||||||
|
submitData.items = values.items || '';
|
||||||
|
} else {
|
||||||
|
submitData.items = Array.isArray(values.items) ? values.items : [];
|
||||||
|
}
|
||||||
|
|
||||||
if (editingGroup.id) {
|
if (editingGroup.id) {
|
||||||
submitData.id = editingGroup.id;
|
submitData.id = editingGroup.id;
|
||||||
@@ -146,11 +158,17 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
|
|||||||
description: editingGroup?.description || '',
|
description: editingGroup?.description || '',
|
||||||
items: (() => {
|
items: (() => {
|
||||||
try {
|
try {
|
||||||
return typeof editingGroup?.items === 'string'
|
if (editingGroup?.type === 'endpoint') {
|
||||||
? JSON.parse(editingGroup.items)
|
// 保持原始字符串
|
||||||
: editingGroup?.items || [];
|
return typeof editingGroup?.items === 'string'
|
||||||
|
? editingGroup.items
|
||||||
|
: JSON.stringify(editingGroup.items || {}, null, 2);
|
||||||
|
}
|
||||||
|
return Array.isArray(editingGroup?.items)
|
||||||
|
? editingGroup.items
|
||||||
|
: [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return editingGroup?.type === 'endpoint' ? '' : [];
|
||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
}}
|
}}
|
||||||
@@ -186,6 +204,7 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
|
|||||||
optionList={typeOptions}
|
optionList={typeOptions}
|
||||||
rules={[{ required: true, message: t('请选择组类型') }]}
|
rules={[{ required: true, message: t('请选择组类型') }]}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
|
onChange={(val) => setSelectedType(val)}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
@@ -213,14 +232,26 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
|
|||||||
</div>
|
</div>
|
||||||
<Row gutter={12}>
|
<Row gutter={12}>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Form.TagInput
|
{selectedType === 'endpoint' ? (
|
||||||
field="items"
|
<JSONEditor
|
||||||
label={t('项目')}
|
field="items"
|
||||||
placeholder={t('输入项目名称,按回车添加')}
|
label={t('端点映射')}
|
||||||
addOnBlur
|
value={formRef.current?.getValue('items') ?? (typeof editingGroup?.items === 'string' ? editingGroup.items : JSON.stringify(editingGroup.items || {}, null, 2))}
|
||||||
showClear
|
onChange={(val) => formRef.current?.setValue('items', val)}
|
||||||
style={{ width: '100%' }}
|
editorType='object'
|
||||||
/>
|
placeholder={'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
|
||||||
|
extraText={t('键为端点类型,值为路径和方法对象')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Form.TagInput
|
||||||
|
field="items"
|
||||||
|
label={t('项目')}
|
||||||
|
placeholder={t('输入项目名称,按回车添加')}
|
||||||
|
addOnBlur
|
||||||
|
showClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -137,8 +137,22 @@ const PrefillGroupManagement = ({ visible, onClose }) => {
|
|||||||
title: t('项目内容'),
|
title: t('项目内容'),
|
||||||
dataIndex: 'items',
|
dataIndex: 'items',
|
||||||
key: 'items',
|
key: 'items',
|
||||||
render: (items) => {
|
render: (items, record) => {
|
||||||
try {
|
try {
|
||||||
|
if (record.type === 'endpoint') {
|
||||||
|
const obj = typeof items === 'string' ? JSON.parse(items || '{}') : (items || {});
|
||||||
|
const keys = Object.keys(obj);
|
||||||
|
if (keys.length === 0) return <Text type="tertiary">{t('暂无项目')}</Text>;
|
||||||
|
return renderLimitedItems({
|
||||||
|
items: keys,
|
||||||
|
renderItem: (key, idx) => (
|
||||||
|
<Tag key={idx} size="small" shape='circle' color={stringToColor(key)}>
|
||||||
|
{key}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
maxDisplay: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
const itemsArray = typeof items === 'string' ? JSON.parse(items) : items;
|
const itemsArray = typeof items === 'string' ? JSON.parse(items) : items;
|
||||||
if (!Array.isArray(itemsArray) || itemsArray.length === 0) {
|
if (!Array.isArray(itemsArray) || itemsArray.length === 0) {
|
||||||
return <Text type="tertiary">{t('暂无项目')}</Text>;
|
return <Text type="tertiary">{t('暂无项目')}</Text>;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const useModelPricingData = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [groupRatio, setGroupRatio] = useState({});
|
const [groupRatio, setGroupRatio] = useState({});
|
||||||
const [usableGroup, setUsableGroup] = useState({});
|
const [usableGroup, setUsableGroup] = useState({});
|
||||||
|
const [endpointMap, setEndpointMap] = useState({});
|
||||||
|
|
||||||
const [statusState] = useContext(StatusContext);
|
const [statusState] = useContext(StatusContext);
|
||||||
const [userState] = useContext(UserContext);
|
const [userState] = useContext(UserContext);
|
||||||
@@ -159,7 +160,7 @@ export const useModelPricingData = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
let url = '/api/pricing';
|
let url = '/api/pricing';
|
||||||
const res = await API.get(url);
|
const res = await API.get(url);
|
||||||
const { success, message, data, vendors, group_ratio, usable_group } = res.data;
|
const { success, message, data, vendors, group_ratio, usable_group, supported_endpoint } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
setGroupRatio(group_ratio);
|
setGroupRatio(group_ratio);
|
||||||
setUsableGroup(usable_group);
|
setUsableGroup(usable_group);
|
||||||
@@ -172,6 +173,7 @@ export const useModelPricingData = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setVendorsMap(vendorMap);
|
setVendorsMap(vendorMap);
|
||||||
|
setEndpointMap(supported_endpoint || {});
|
||||||
setModelsFormat(data, group_ratio, vendorMap);
|
setModelsFormat(data, group_ratio, vendorMap);
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
@@ -279,6 +281,7 @@ export const useModelPricingData = () => {
|
|||||||
loading,
|
loading,
|
||||||
groupRatio,
|
groupRatio,
|
||||||
usableGroup,
|
usableGroup,
|
||||||
|
endpointMap,
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
priceRate,
|
priceRate,
|
||||||
|
|||||||
Reference in New Issue
Block a user