✨ 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:
@@ -80,6 +80,7 @@ const PricingPage = () => {
|
||||
displayPrice={pricingData.displayPrice}
|
||||
showRatio={allProps.showRatio}
|
||||
vendorsMap={pricingData.vendorsMap}
|
||||
endpointMap={pricingData.endpointMap}
|
||||
t={pricingData.t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 endpoints.map((endpoint, index) => (
|
||||
<div
|
||||
key={index}
|
||||
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
|
||||
</span>
|
||||
<span className="text-gray-500 text-xs hidden md:inline">POST</span>
|
||||
</div>
|
||||
));
|
||||
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);
|
||||
}
|
||||
const method = info.method || 'POST';
|
||||
return (
|
||||
<div
|
||||
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" />
|
||||
{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 (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 {
|
||||
return typeof editingGroup?.items === 'string'
|
||||
? JSON.parse(editingGroup.items)
|
||||
: editingGroup?.items || [];
|
||||
if (editingGroup?.type === 'endpoint') {
|
||||
// 保持原始字符串
|
||||
return typeof editingGroup?.items === 'string'
|
||||
? 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,14 +232,26 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.TagInput
|
||||
field="items"
|
||||
label={t('项目')}
|
||||
placeholder={t('输入项目名称,按回车添加')}
|
||||
addOnBlur
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{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('项目')}
|
||||
placeholder={t('输入项目名称,按回车添加')}
|
||||
addOnBlur
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user