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:
t0ng7u
2025-08-08 02:34:15 +08:00
parent 7cfeb6e87c
commit 8fba0017c7
11 changed files with 614 additions and 458 deletions

View 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
}

View File

@@ -44,6 +44,7 @@ func GetPricing(c *gin.Context) {
"vendors": model.GetVendors(),
"group_ratio": groupRatio,
"usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
})
}

View File

@@ -1,8 +1,10 @@
package model
import (
"encoding/json"
"fmt"
"strings"
"one-api/common"
"one-api/constant"
"one-api/setting/ratio_setting"
@@ -35,6 +37,7 @@ type PricingVendor struct {
var (
pricingMap []Pricing
vendorsList []PricingVendor
supportedEndpointMap map[string]common.EndpointInfo
lastGetPricingTime time.Time
updatePricingLock sync.Mutex
@@ -176,12 +179,9 @@ func updatePricing() {
//这里使用切片而不是Set因为一个模型可能支持多个端点类型并且第一个端点是优先使用端点
modelSupportEndpointsStr := make(map[string][]string)
// 先根据已有能力填充原生端点
for _, ability := range enableAbilities {
endpoints, ok := modelSupportEndpointsStr[ability.Model]
if !ok {
endpoints = make([]string, 0)
modelSupportEndpointsStr[ability.Model] = endpoints
}
endpoints := modelSupportEndpointsStr[ability.Model]
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
for _, channelType := range channelTypes {
if !common.StringsContains(endpoints, string(channelType)) {
@@ -191,6 +191,23 @@ func updatePricing() {
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)
for model, endpoints := range modelSupportEndpointsStr {
supportedEndpoints := make([]constant.EndpointType, 0)
@@ -201,6 +218,45 @@ func updatePricing() {
modelSupportEndpointTypes[model] = supportedEndpoints
}
// 构建全局 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 {
pricing := Pricing{
@@ -244,3 +300,8 @@ func updatePricing() {
lastGetPricingTime = time.Now()
}
// GetSupportedEndpointMap 返回全局端点到路径的映射
func GetSupportedEndpointMap() map[string]common.EndpointInfo {
return supportedEndpointMap
}

View File

@@ -1,25 +1,25 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Space,
Button,
Form,
Card,
Typography,
Banner,
Row,
Col,
Tabs,
TabPane,
Card,
Input,
InputNumber,
Switch,
Select,
Input,
TextArea,
Row,
Col,
} from '@douyinfe/semi-ui';
import {
IconCode,
IconEdit,
IconPlus,
IconDelete,
IconSetting,
IconRefresh,
} from '@douyinfe/semi-icons';
const { Text } = Typography;
@@ -34,8 +34,7 @@ const JSONEditor = ({
showClear = true,
template,
templateLabel,
editorType = 'keyValue', // keyValue, object, region
autosize = true,
editorType = 'keyValue',
rules = [],
formApi = null,
...props
@@ -45,7 +44,7 @@ const JSONEditor = ({
// 初始化JSON数据
const [jsonData, setJsonData] = useState(() => {
// 初始化时解析JSON数据
if (value && value.trim()) {
if (typeof value === 'string' && value.trim()) {
try {
const parsed = JSON.parse(value);
return parsed;
@@ -53,13 +52,16 @@ const JSONEditor = ({
return {};
}
}
if (typeof value === 'object' && value !== null) {
return value;
}
return {};
});
// 根据键数量决定默认编辑模式
const [editMode, setEditMode] = useState(() => {
// 如果初始JSON数据的键数量大于10个则默认使用手动模式
if (value && value.trim()) {
if (typeof value === 'string' && value.trim()) {
try {
const parsed = JSON.parse(value);
const keyCount = Object.keys(parsed).length;
@@ -76,7 +78,12 @@ const JSONEditor = ({
// 数据同步 - 当value变化时总是更新jsonData如果JSON有效
useEffect(() => {
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);
setJsonError('');
} catch (error) {
@@ -86,7 +93,6 @@ const JSONEditor = ({
}
}, [value]);
// 处理可视化编辑的数据变化
const handleVisualChange = useCallback((newData) => {
setJsonData(newData);
@@ -127,7 +133,12 @@ const JSONEditor = ({
} else {
// 从手动模式切换到可视化模式需要验证JSON
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);
setJsonError('');
setEditMode('visual');
@@ -143,11 +154,11 @@ const JSONEditor = ({
const addKeyValue = useCallback(() => {
const newData = { ...jsonData };
const keys = Object.keys(newData);
let newKey = 'key';
let counter = 1;
let newKey = `field_${counter}`;
while (newData.hasOwnProperty(newKey)) {
newKey = `key${counter}`;
counter++;
counter += 1;
newKey = `field_${counter}`;
}
newData[newKey] = '';
handleVisualChange(newData);
@@ -162,11 +173,15 @@ const JSONEditor = ({
// 更新键名
const updateKey = useCallback((oldKey, newKey) => {
if (oldKey === newKey) return;
const newData = { ...jsonData };
const value = newData[oldKey];
delete newData[oldKey];
newData[newKey] = value;
if (oldKey === newKey || !newKey) return;
const newData = {};
Object.entries(jsonData).forEach(([k, v]) => {
if (k === oldKey) {
newData[newKey] = v;
} else {
newData[k] = v;
}
});
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
@@ -220,9 +235,6 @@ const JSONEditor = ({
<div className="space-y-1">
{entries.length === 0 && (
<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">
{t('暂无数据,点击下方按钮添加键值对')}
</Text>
@@ -230,54 +242,35 @@ const JSONEditor = ({
)}
{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={10}>
<div className="space-y-1">
<Text type="tertiary" size="small">{t('键名')}</Text>
<Row key={index} gutter={8} align="middle">
<Col span={6}>
<Input
placeholder={t('键名')}
value={key}
onChange={(newKey) => updateKey(key, newKey)}
size="small"
/>
</div>
</Col>
<Col span={11}>
<div className="space-y-1">
<Text type="tertiary" size="small">{t('值')}</Text>
<Input
placeholder={t('值')}
value={value}
onChange={(newValue) => updateValue(key, newValue)}
size="small"
/>
</div>
<Col span={16}>
{renderValueInput(key, value)}
</Col>
<Col span={3}>
<div className="flex justify-center pt-4">
<Col span={2}>
<Button
icon={<IconDelete />}
type="danger"
theme="borderless"
size="small"
onClick={() => removeKeyValue(key)}
className="hover:bg-red-50"
style={{ width: '100%' }}
/>
</div>
</Col>
</Row>
</Card>
))}
<div className="flex justify-center pt-1">
<div className="mt-2 flex justify-center">
<Button
icon={<IconPlus />}
onClick={addKeyValue}
size="small"
theme="solid"
type="primary"
className="shadow-sm hover:shadow-md transition-shadow px-4"
theme="outline"
onClick={addKeyValue}
>
{t('添加键值对')}
</Button>
@@ -286,76 +279,39 @@ const JSONEditor = ({
);
};
// 渲染对象编辑器用于复杂JSON
const renderObjectEditor = () => {
const entries = Object.entries(jsonData);
// 添加嵌套对象
const flattenObject = useCallback((parentKey) => {
const newData = { ...jsonData };
let primitive = '';
const obj = newData[parentKey];
if (obj && typeof obj === 'object') {
const firstKey = Object.keys(obj)[0];
if (firstKey !== undefined) {
const firstVal = obj[firstKey];
if (typeof firstVal !== 'object') primitive = firstVal;
}
}
newData[parentKey] = primitive;
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
return (
<div className="space-y-1">
{entries.length === 0 && (
<div className="text-center py-6 px-4">
<div className="text-gray-400 mb-2">
<IconSetting size={32} />
</div>
<Text type="tertiary" className="text-gray-500 text-sm">
{t('暂无参数,点击下方按钮添加请求参数')}
</Text>
</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]);
{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 renderValueInput = (key, value) => {
const valueType = typeof value;
@@ -365,9 +321,8 @@ const JSONEditor = ({
<Switch
checked={value}
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')}
</Text>
</div>
@@ -379,7 +334,6 @@ const JSONEditor = ({
<InputNumber
value={value}
onChange={(newValue) => updateValue(key, newValue)}
size="small"
style={{ width: '100%' }}
step={key === 'temperature' ? 0.1 : 1}
precision={key === 'temperature' ? 2 : 0}
@@ -388,24 +342,136 @@ 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
placeholder={t('参数值')}
value={String(value)}
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 (
<div className="flex items-center gap-1">
<Input
placeholder={t('参数值')}
value={String(value)}
onChange={(newValue) => {
let convertedValue = newValue;
if (newValue === 'true') convertedValue = true;
else if (newValue === 'false') convertedValue = false;
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
convertedValue = Number(newValue);
}
updateValue(key, convertedValue);
}}
size="small"
/>
<Button
icon={<IconPlus />}
type="tertiary"
onClick={() => {
// 将当前值转换为对象
const newData = { ...jsonData };
newData[key] = { '1': value };
handleVisualChange(newData);
}}
title={t('转换为对象')}
/>
</div>
);
};
@@ -416,77 +482,59 @@ const JSONEditor = ({
const modelEntries = entries.filter(([key]) => key !== 'default');
return (
<div className="space-y-1">
<div className="space-y-2">
{/* 默认区域 */}
<Card className="!p-2 !border-blue-200 !bg-blue-50">
<div className="flex items-center mb-1">
<Text strong size="small" className="text-blue-700">{t('默认区域')}</Text>
</div>
<Form.Slot label={t('默认区域')}>
<Input
placeholder={t('默认区域,如: us-central1')}
value={defaultEntry ? defaultEntry[1] : ''}
onChange={(value) => updateValue('default', value)}
size="small"
/>
</Card>
</Form.Slot>
{/* 模型专用区域 */}
<div className="space-y-1">
<Text strong size="small">{t('模型专用区域')}</Text>
<Form.Slot label={t('模型专用区域')}>
<div>
{modelEntries.map(([modelName, region], index) => (
<Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
<Row gutter={12} align="middle">
<Row key={index} gutter={8} align="middle" className="mb-2">
<Col span={10}>
<div className="space-y-1">
<Text type="tertiary" size="small">{t('模型名称')}</Text>
<Input
placeholder={t('模型名称')}
value={modelName}
onChange={(newKey) => updateKey(modelName, newKey)}
size="small"
/>
</div>
</Col>
<Col span={11}>
<div className="space-y-1">
<Text type="tertiary" size="small">{t('区域')}</Text>
<Col span={12}>
<Input
placeholder={t('区域')}
value={region}
onChange={(newValue) => updateValue(modelName, newValue)}
size="small"
/>
</div>
</Col>
<Col span={3}>
<div className="flex justify-center pt-4">
<Col span={2}>
<Button
icon={<IconDelete />}
type="danger"
theme="borderless"
size="small"
onClick={() => removeKeyValue(modelName)}
className="hover:bg-red-50"
style={{ width: '100%' }}
/>
</div>
</Col>
</Row>
</Card>
))}
<div className="flex justify-center pt-1">
<div className="mt-2 flex justify-center">
<Button
icon={<IconPlus />}
onClick={addKeyValue}
size="small"
theme="solid"
type="primary"
className="shadow-sm hover:shadow-md transition-shadow px-4"
theme="outline"
>
{t('添加模型区域')}
</Button>
</div>
</div>
</Form.Slot>
</div>
);
};
@@ -497,7 +545,6 @@ const JSONEditor = ({
case 'region':
return renderRegionEditor();
case 'object':
return renderObjectEditor();
case 'keyValue':
default:
return renderKeyValueEditor();
@@ -507,84 +554,53 @@ const JSONEditor = ({
const hasJsonError = jsonError && jsonError.trim() !== '';
return (
<div className="space-y-1">
{/* Label统一显示在上方 */}
{label && (
<div className="flex items-center">
<Text className="text-sm font-medium text-gray-900">{label}</Text>
</div>
)}
<Form.Slot label={label}>
<Card
header={
<div className="flex justify-between items-center">
<Tabs
type="slash"
activeKey={editMode}
onChange={(key) => {
if (key === 'manual' && editMode === 'visual') {
setEditMode('manual');
} else if (key === 'visual' && editMode === 'manual') {
toggleEditMode();
}
}}
>
<TabPane tab={t('可视化')} itemKey="visual" />
<TabPane tab={t('手动编辑')} itemKey="manual" />
</Tabs>
{/* 编辑模式切换 */}
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
<div className="flex items-center gap-2">
{editMode === 'visual' && (
<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"
size="small"
>
{templateLabel}
</Button>
)}
<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>
}
headerStyle={{ padding: '12px 16px' }}
bodyStyle={{ padding: '16px' }}
className="!rounded-2xl"
>
{/* JSON错误提示 */}
{hasJsonError && (
<Banner
type="danger"
description={`JSON 格式错误: ${jsonError}`}
className="!rounded-md text-sm"
className="mb-3"
/>
)}
{/* 编辑器内容 */}
{editMode === 'visual' ? (
<div>
<Card className="!p-3 !border-gray-200 !shadow-sm !rounded-md bg-white">
{renderVisualEditor()}
</Card>
{/* 可视化模式下的额外文本显示在下方 */}
{extraText && (
<div className="text-xs text-gray-600 mt-0.5">
{extraText}
</div>
)}
{/* 隐藏的Form字段用于验证和数据绑定 */}
<Form.Input
field={field}
@@ -596,26 +612,34 @@ const JSONEditor = ({
/>
</div>
) : (
<Form.TextArea
field={field}
<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 && editMode === 'manual' && (
<div className="text-xs text-gray-600">
{/* 额外文本显示在卡片底部 */}
{extraText && (
<div className="text-gray-600 mt-3 pt-3">
{extraText}
</div>
)}
</div>
</Card>
</Form.Slot>
);
};

View File

@@ -80,6 +80,7 @@ const PricingPage = () => {
displayPrice={pricingData.displayPrice}
showRatio={allProps.showRatio}
vendorsMap={pricingData.vendorsMap}
endpointMap={pricingData.endpointMap}
t={pricingData.t}
/>
</div>

View File

@@ -47,6 +47,7 @@ const ModelDetailSideSheet = ({
showRatio,
usableGroup,
vendorsMap,
endpointMap,
t,
}) => {
const isMobile = useIsMobile();
@@ -82,7 +83,7 @@ const ModelDetailSideSheet = ({
{modelData && (
<>
<ModelBasicInfo modelData={modelData} vendorsMap={vendorsMap} t={t} />
<ModelEndpoints modelData={modelData} t={t} />
<ModelEndpoints modelData={modelData} endpointMap={endpointMap} t={t} />
<ModelPricingTable
modelData={modelData}
selectedGroup={selectedGroup}

View File

@@ -23,31 +23,45 @@ import { IconLink } from '@douyinfe/semi-icons';
const { Text } = Typography;
const ModelEndpoints = ({ modelData, t }) => {
const ModelEndpoints = ({ modelData, endpointMap = {}, t }) => {
const renderAPIEndpoints = () => {
const endpoints = [];
if (!modelData) return null;
if (modelData?.supported_endpoint_types) {
modelData.supported_endpoint_types.forEach(endpoint => {
endpoints.push({ name: endpoint, type: endpoint });
});
const mapping = endpointMap;
const types = modelData.supported_endpoint_types || [];
return types.map(type => {
const info = mapping[type] || {};
let path = info.path || '';
// 如果路径中包含 {model} 占位符,替换为真实模型名称
if (path.includes('{model}')) {
const modelName = modelData.model_name || modelData.modelName || '';
path = path.replaceAll('{model}', modelName);
}
return endpoints.map((endpoint, index) => (
const method = info.method || 'POST';
return (
<div
key={index}
key={type}
className="flex justify-between border-b border-dashed last:border-0 py-2 last:pb-0"
style={{ borderColor: 'var(--semi-color-border)' }}
>
<span className="flex items-center pr-5">
<Badge dot type="success" className="mr-2" />
{endpoint.name}
<span className="text-gray-500 hidden md:inline">https://api.newapi.pro</span>
/v1/chat/completions
{type}{path && ''}
{path && (
<span className="text-gray-500 md:ml-1 break-all">
{path}
</span>
<span className="text-gray-500 text-xs hidden md:inline">POST</span>
)}
</span>
{path && (
<span className="text-gray-500 text-xs md:ml-1">
{method}
</span>
)}
</div>
));
);
});
};
return (

View File

@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect, useRef, useMemo } from 'react';
import JSONEditor from '../../../common/ui/JSONEditor';
import {
SideSheet,
Form,
@@ -109,7 +110,7 @@ const EditModelModal = (props) => {
vendor_id: undefined,
vendor: '',
vendor_icon: '',
endpoints: [],
endpoints: '',
name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
status: true,
});
@@ -132,15 +133,9 @@ const EditModelModal = (props) => {
} else {
data.tags = [];
}
// 处理endpoints
if (data.endpoints) {
try {
data.endpoints = JSON.parse(data.endpoints);
} catch (e) {
data.endpoints = [];
}
} else {
data.endpoints = [];
// endpoints 保持原始 JSON 字符串,若为空设为空串
if (!data.endpoints) {
data.endpoints = '';
}
// 处理status将数字转为布尔值
data.status = data.status === 1;
@@ -188,7 +183,7 @@ const EditModelModal = (props) => {
const submitData = {
...values,
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
endpoints: JSON.stringify(values.endpoints || []),
endpoints: values.endpoints || '',
status: values.status ? 1 : 0,
};
@@ -382,36 +377,15 @@ const EditModelModal = (props) => {
/>
</Col>
<Col span={24}>
<Form.TagInput
<JSONEditor
field='endpoints'
label={t('支持端点')}
placeholder={t('输入端点名称,按回车添加')}
addOnBlur
showClear
style={{ width: '100%' }}
{...(endpointGroups.length > 0 && {
extraText: (
<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>
)
})}
label={t('端点映射')}
placeholder={'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
value={values.endpoints}
onChange={(val) => formApiRef.current?.setValue('endpoints', val)}
formApi={formApiRef.current}
editorType='object'
extraText={t('留空则使用默认端点;支持 {path, method}')}
/>
</Col>
<Col span={24}>

View File

@@ -17,7 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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 {
SideSheet,
Button,
@@ -49,6 +50,13 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
const formRef = useRef(null);
const isEdit = editingGroup && editingGroup.id !== undefined;
const [selectedType, setSelectedType] = useState(editingGroup?.type || 'tag');
// 当外部传入的编辑组类型变化时同步 selectedType
useEffect(() => {
setSelectedType(editingGroup?.type || 'tag');
}, [editingGroup?.type]);
const typeOptions = [
{ label: t('模型组'), value: 'model' },
{ label: t('标签组'), value: 'tag' },
@@ -61,8 +69,12 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
try {
const submitData = {
...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) {
submitData.id = editingGroup.id;
@@ -146,11 +158,17 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
description: editingGroup?.description || '',
items: (() => {
try {
if (editingGroup?.type === 'endpoint') {
// 保持原始字符串
return typeof editingGroup?.items === 'string'
? JSON.parse(editingGroup.items)
: editingGroup?.items || [];
? editingGroup.items
: JSON.stringify(editingGroup.items || {}, null, 2);
}
return Array.isArray(editingGroup?.items)
? editingGroup.items
: [];
} catch {
return [];
return editingGroup?.type === 'endpoint' ? '' : [];
}
})(),
}}
@@ -186,6 +204,7 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
optionList={typeOptions}
rules={[{ required: true, message: t('请选择组类型') }]}
style={{ width: '100%' }}
onChange={(val) => setSelectedType(val)}
/>
</Col>
<Col span={24}>
@@ -213,6 +232,17 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
</div>
<Row gutter={12}>
<Col span={24}>
{selectedType === 'endpoint' ? (
<JSONEditor
field="items"
label={t('端点映射')}
value={formRef.current?.getValue('items') ?? (typeof editingGroup?.items === 'string' ? editingGroup.items : JSON.stringify(editingGroup.items || {}, null, 2))}
onChange={(val) => formRef.current?.setValue('items', val)}
editorType='object'
placeholder={'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
extraText={t('键为端点类型,值为路径和方法对象')}
/>
) : (
<Form.TagInput
field="items"
label={t('项目')}
@@ -221,6 +251,7 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
showClear
style={{ width: '100%' }}
/>
)}
</Col>
</Row>
</Card>

View File

@@ -137,8 +137,22 @@ const PrefillGroupManagement = ({ visible, onClose }) => {
title: t('项目内容'),
dataIndex: 'items',
key: 'items',
render: (items) => {
render: (items, record) => {
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;
if (!Array.isArray(itemsArray) || itemsArray.length === 0) {
return <Text type="tertiary">{t('暂无项目')}</Text>;

View File

@@ -48,6 +48,7 @@ export const useModelPricingData = () => {
const [loading, setLoading] = useState(true);
const [groupRatio, setGroupRatio] = useState({});
const [usableGroup, setUsableGroup] = useState({});
const [endpointMap, setEndpointMap] = useState({});
const [statusState] = useContext(StatusContext);
const [userState] = useContext(UserContext);
@@ -159,7 +160,7 @@ export const useModelPricingData = () => {
setLoading(true);
let url = '/api/pricing';
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) {
setGroupRatio(group_ratio);
setUsableGroup(usable_group);
@@ -172,6 +173,7 @@ export const useModelPricingData = () => {
});
}
setVendorsMap(vendorMap);
setEndpointMap(supported_endpoint || {});
setModelsFormat(data, group_ratio, vendorMap);
} else {
showError(message);
@@ -279,6 +281,7 @@ export const useModelPricingData = () => {
loading,
groupRatio,
usableGroup,
endpointMap,
// 计算属性
priceRate,