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

@@ -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>