✨ feat: Improve models UX and robustness: add JSONEditor extraFooter, fix endpoints rendering, and clean up deps
- Why
- Needed to separate help text from action buttons in JSONEditor for better layout and UX.
- Models table should robustly render both new object-based endpoint mappings and legacy arrays.
- Columns should re-render when vendor map changes.
- Minor import cleanups for consistency.
- What
- JSONEditor.js
- Added optional prop extraFooter to render content below the extraText divider.
- Kept extraText rendered via Divider; extraFooter appears on the next line for clear separation.
- EditModelModal.jsx
- Moved endpoint group buttons from extraText into extraFooter to display under the helper text.
- Kept merge-logic: group items are merged into current endpoints JSON with key override semantics.
- Consolidated lucide-react imports into a single line.
- ModelsColumnDefs.js
- Made endpoint renderer resilient:
- Supports object-based JSON (keys as endpoint types) and legacy array format.
- Displays keys/items as tags and limits the number shown; uses stringToColor for visual consistency.
- Consolidated Semi UI imports into a single line.
- ModelsTable.jsx
- Fixed columns memoization dependency to include vendorMap, ensuring re-render when vendor data changes.
- Notes
- Backward-compatible: extraFooter is additive; existing JSONEditor usage remains unchanged.
- No API changes to backend.
- No linter errors introduced.
- Files touched
- web/src/components/common/ui/JSONEditor.js
- web/src/components/table/models/modals/EditModelModal.jsx
- web/src/components/table/models/ModelsColumnDefs.js
- web/src/components/table/models/ModelsTable.jsx
- Impact
- Clearer UI for endpoint editing (buttons now below helper text).
- Correct endpoints display for object-based mappings in models list.
- More reliable reactivity when vendor data updates.
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
TextArea,
|
TextArea,
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
|
Divider,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
IconCode,
|
IconCode,
|
||||||
@@ -31,6 +32,7 @@ const JSONEditor = ({
|
|||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
extraText,
|
extraText,
|
||||||
|
extraFooter,
|
||||||
showClear = true,
|
showClear = true,
|
||||||
template,
|
template,
|
||||||
templateLabel,
|
templateLabel,
|
||||||
@@ -634,8 +636,13 @@ const JSONEditor = ({
|
|||||||
|
|
||||||
{/* 额外文本显示在卡片底部 */}
|
{/* 额外文本显示在卡片底部 */}
|
||||||
{extraText && (
|
{extraText && (
|
||||||
<div className="text-gray-600 mt-3 pt-3">
|
<Divider margin='12px' align='center'>
|
||||||
{extraText}
|
{extraText}
|
||||||
|
</Divider>
|
||||||
|
)}
|
||||||
|
{extraFooter && (
|
||||||
|
<div className="mt-1">
|
||||||
|
{extraFooter}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import { Button, Space, Tag, Typography, Modal } from '@douyinfe/semi-ui';
|
||||||
Button,
|
|
||||||
Space,
|
|
||||||
Tag,
|
|
||||||
Typography,
|
|
||||||
Modal
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import {
|
import {
|
||||||
timestamp2string,
|
timestamp2string,
|
||||||
getLobeHubIcon,
|
getLobeHubIcon,
|
||||||
@@ -81,21 +75,39 @@ const renderTags = (text) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render endpoints
|
// Render endpoints (supports object map or legacy array)
|
||||||
const renderEndpoints = (text) => {
|
const renderEndpoints = (value) => {
|
||||||
let arr;
|
|
||||||
try {
|
try {
|
||||||
arr = JSON.parse(text);
|
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||||
} catch (_) { }
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
if (!Array.isArray(arr)) return text || '-';
|
const keys = Object.keys(parsed || {});
|
||||||
return renderLimitedItems({
|
if (keys.length === 0) return '-';
|
||||||
items: arr,
|
return renderLimitedItems({
|
||||||
renderItem: (ep, idx) => (
|
items: keys,
|
||||||
<Tag key={idx} color="white" size="small" shape='circle'>
|
renderItem: (key, idx) => (
|
||||||
{ep}
|
<Tag key={idx} size="small" shape='circle' color={stringToColor(key)}>
|
||||||
</Tag>
|
{key}
|
||||||
),
|
</Tag>
|
||||||
});
|
),
|
||||||
|
maxDisplay: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
if (parsed.length === 0) return '-';
|
||||||
|
return renderLimitedItems({
|
||||||
|
items: parsed,
|
||||||
|
renderItem: (ep, idx) => (
|
||||||
|
<Tag key={idx} color="white" size="small" shape='circle'>
|
||||||
|
{ep}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
maxDisplay: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value || '-';
|
||||||
|
} catch (_) {
|
||||||
|
return value || '-';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render quota type
|
// Render quota type
|
||||||
|
|||||||
@@ -56,13 +56,7 @@ const ModelsTable = (modelsData) => {
|
|||||||
refresh,
|
refresh,
|
||||||
vendorMap,
|
vendorMap,
|
||||||
});
|
});
|
||||||
}, [
|
}, [t, manageModel, setEditingModel, setShowEdit, refresh, vendorMap]);
|
||||||
t,
|
|
||||||
manageModel,
|
|
||||||
setEditingModel,
|
|
||||||
setShowEdit,
|
|
||||||
refresh,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Handle compact mode by removing fixed positioning
|
// Handle compact mode by removing fixed positioning
|
||||||
const tableColumns = useMemo(() => {
|
const tableColumns = useMemo(() => {
|
||||||
|
|||||||
@@ -32,17 +32,20 @@ import {
|
|||||||
Col,
|
Col,
|
||||||
Row,
|
Row,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import { Save, X, FileText } from 'lucide-react';
|
||||||
Save,
|
|
||||||
X,
|
|
||||||
FileText,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { API, showError, showSuccess } from '../../../../helpers';
|
import { API, showError, showSuccess } from '../../../../helpers';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
// Example endpoint template for quick fill
|
||||||
|
const ENDPOINT_TEMPLATE = {
|
||||||
|
openai: { path: '/v1/chat/completions', method: 'POST' },
|
||||||
|
anthropic: { path: '/v1/messages', method: 'POST' },
|
||||||
|
'image-generation': { path: '/v1/images/generations', method: 'POST' },
|
||||||
|
};
|
||||||
|
|
||||||
const nameRuleOptions = [
|
const nameRuleOptions = [
|
||||||
{ label: '精确名称匹配', value: 0 },
|
{ label: '精确名称匹配', value: 0 },
|
||||||
{ label: '前缀名称匹配', value: 1 },
|
{ label: '前缀名称匹配', value: 1 },
|
||||||
@@ -385,7 +388,37 @@ const EditModelModal = (props) => {
|
|||||||
onChange={(val) => formApiRef.current?.setValue('endpoints', val)}
|
onChange={(val) => formApiRef.current?.setValue('endpoints', val)}
|
||||||
formApi={formApiRef.current}
|
formApi={formApiRef.current}
|
||||||
editorType='object'
|
editorType='object'
|
||||||
extraText={t('留空则使用默认端点;支持 {path, method}')}
|
template={ENDPOINT_TEMPLATE}
|
||||||
|
templateLabel={t('填入模板')}
|
||||||
|
extraText={(<Text type="tertiary" size="small">{t('留空则使用默认端点;支持 {path, method}')}</Text>)}
|
||||||
|
extraFooter={endpointGroups.length > 0 && (
|
||||||
|
<Space wrap>
|
||||||
|
{endpointGroups.map(group => (
|
||||||
|
<Button
|
||||||
|
key={group.id}
|
||||||
|
size='small'
|
||||||
|
type='primary'
|
||||||
|
onClick={() => {
|
||||||
|
try {
|
||||||
|
const current = formApiRef.current?.getValue('endpoints') || '';
|
||||||
|
let base = {};
|
||||||
|
if (current && current.trim()) base = JSON.parse(current);
|
||||||
|
const groupObj = typeof group.items === 'string' ? JSON.parse(group.items || '{}') : (group.items || {});
|
||||||
|
const merged = { ...base, ...groupObj };
|
||||||
|
formApiRef.current?.setValue('endpoints', JSON.stringify(merged, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
const groupObj = typeof group.items === 'string' ? JSON.parse(group.items || '{}') : (group.items || {});
|
||||||
|
formApiRef.current?.setValue('endpoints', JSON.stringify(groupObj, null, 2));
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
|||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
// Example endpoint template for quick fill
|
||||||
|
const ENDPOINT_TEMPLATE = {
|
||||||
|
openai: { path: '/v1/chat/completions', method: 'POST' },
|
||||||
|
anthropic: { path: '/v1/messages', method: 'POST' },
|
||||||
|
'image-generation': { path: '/v1/images/generations', method: 'POST' },
|
||||||
|
};
|
||||||
|
|
||||||
const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => {
|
const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -240,6 +247,8 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
|
|||||||
onChange={(val) => formRef.current?.setValue('items', val)}
|
onChange={(val) => formRef.current?.setValue('items', val)}
|
||||||
editorType='object'
|
editorType='object'
|
||||||
placeholder={'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
|
placeholder={'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
|
||||||
|
template={ENDPOINT_TEMPLATE}
|
||||||
|
templateLabel={t('填入模板')}
|
||||||
extraText={t('键为端点类型,值为路径和方法对象')}
|
extraText={t('键为端点类型,值为路径和方法对象')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user