✨ feat(models-sync): official upstream sync with conflict resolution UI, opt‑out flag, and backend resiliency
Backend - Add endpoints: - GET /api/models/sync_upstream/preview — diff preview (filters out models with sync_official = 0) - POST /api/models/sync_upstream — apply sync (create missing; optionally overwrite selected fields) - Respect opt‑out: skip models with sync_official = 0 in both preview and apply - Return detailed stats: created_models, created_vendors, updated_models, skipped_models, plus created_list / updated_list - Add model.Model.SyncOfficial (default 1); auto‑migrated by GORM - Make HTTP fetching robust: - Shared http.Client (connection reuse) with 3x exponential backoff retry - 10MB response cap; keep existing IPv4‑first for *.github.io - Vendor handling: - New ensureVendorID helper (cache lookup → DB lookup → create), reduces round‑trips - Transactional overwrite to avoid partial updates - Small cleanups and clearer helpers (containsField, coalesce, chooseStatus) Frontend - ModelsActions: add “Sync official” button with Popover (p‑2) explaining community contribution; loading = syncing || previewing; preview → conflict modal → apply flow - New UpstreamConflictModal: - Per‑field columns (description/icon/tags/vendor/name_rule/status) with column‑level checkbox to select all - Cell with Checkbox + Tag (“Click to view differences”) and Popover (p‑2) showing Local vs Official values - Auto‑hide columns with no conflicts; responsive width; use native Semi Modal footer - Full i18n coverage - useModelsData: add syncing/previewing states; new methods previewUpstreamDiff, applyUpstreamOverwrite, syncUpstream; refresh vendors/models after apply - EditModelModal: add “Participate in official sync” switch; persisted as sync_official - ModelsColumnDefs: add “Participate in official sync” column i18n - Add missing English keys for the new UI and messages; fix quoting issues Refs - Upstream metadata: https://github.com/basellm/llm-metadata
This commit is contained in:
@@ -21,10 +21,11 @@ import React, { useState } from 'react';
|
||||
import MissingModelsModal from './modals/MissingModelsModal';
|
||||
import PrefillGroupManagement from './modals/PrefillGroupManagement';
|
||||
import EditPrefillGroupModal from './modals/EditPrefillGroupModal';
|
||||
import { Button, Modal } from '@douyinfe/semi-ui';
|
||||
import { Button, Modal, Popover } from '@douyinfe/semi-ui';
|
||||
import { showSuccess, showError, copy } from '../../../helpers';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
import SelectionNotification from './components/SelectionNotification';
|
||||
import UpstreamConflictModal from './modals/UpstreamConflictModal';
|
||||
|
||||
const ModelsActions = ({
|
||||
selectedKeys,
|
||||
@@ -32,6 +33,11 @@ const ModelsActions = ({
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
batchDeleteModels,
|
||||
syncing,
|
||||
previewing,
|
||||
syncUpstream,
|
||||
previewUpstreamDiff,
|
||||
applyUpstreamOverwrite,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
t,
|
||||
@@ -42,6 +48,21 @@ const ModelsActions = ({
|
||||
const [showGroupManagement, setShowGroupManagement] = useState(false);
|
||||
const [showAddPrefill, setShowAddPrefill] = useState(false);
|
||||
const [prefillInit, setPrefillInit] = useState({ id: undefined });
|
||||
const [showConflict, setShowConflict] = useState(false);
|
||||
const [conflicts, setConflicts] = useState([]);
|
||||
|
||||
const handleSyncUpstream = async () => {
|
||||
// 先预览
|
||||
const data = await previewUpstreamDiff?.();
|
||||
const conflictItems = data?.conflicts || [];
|
||||
if (conflictItems.length > 0) {
|
||||
setConflicts(conflictItems);
|
||||
setShowConflict(true);
|
||||
return;
|
||||
}
|
||||
// 无冲突,直接同步缺失
|
||||
await syncUpstream?.();
|
||||
};
|
||||
|
||||
// Handle delete selected models with confirmation
|
||||
const handleDeleteSelectedModels = () => {
|
||||
@@ -104,6 +125,38 @@ const ModelsActions = ({
|
||||
{t('未配置模型')}
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
position='bottom'
|
||||
trigger='hover'
|
||||
content={
|
||||
<div className='p-2 max-w-[360px]'>
|
||||
<div className='text-[var(--semi-color-text-2)] text-sm'>
|
||||
{t(
|
||||
'模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:',
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href='https://github.com/basellm/llm-metadata'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-blue-600 underline'
|
||||
>
|
||||
https://github.com/basellm/llm-metadata
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type='secondary'
|
||||
className='flex-1 md:flex-initial'
|
||||
size='small'
|
||||
loading={syncing || previewing}
|
||||
onClick={handleSyncUpstream}
|
||||
>
|
||||
{t('同步官方')}
|
||||
</Button>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
type='secondary'
|
||||
className='flex-1 md:flex-initial'
|
||||
@@ -165,6 +218,17 @@ const ModelsActions = ({
|
||||
editingGroup={prefillInit}
|
||||
onSuccess={() => setShowAddPrefill(false)}
|
||||
/>
|
||||
|
||||
<UpstreamConflictModal
|
||||
visible={showConflict}
|
||||
onClose={() => setShowConflict(false)}
|
||||
conflicts={conflicts}
|
||||
onSubmit={async (payload) => {
|
||||
return await applyUpstreamOverwrite?.(payload);
|
||||
}}
|
||||
t={t}
|
||||
loading={syncing}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -303,6 +303,15 @@ export const getModelsColumns = ({
|
||||
dataIndex: 'name_rule',
|
||||
render: (val, record) => renderNameRule(val, record, t),
|
||||
},
|
||||
{
|
||||
title: t('参与官方同步'),
|
||||
dataIndex: 'sync_official',
|
||||
render: (val) => (
|
||||
<Tag size='small' shape='circle' color={val === 1 ? 'green' : 'orange'}>
|
||||
{val === 1 ? t('是') : t('否')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
|
||||
@@ -105,6 +105,11 @@ const ModelsPage = () => {
|
||||
setEditingModel={setEditingModel}
|
||||
setShowEdit={setShowEdit}
|
||||
batchDeleteModels={batchDeleteModels}
|
||||
syncing={modelsData.syncing}
|
||||
syncUpstream={modelsData.syncUpstream}
|
||||
previewing={modelsData.previewing}
|
||||
previewUpstreamDiff={modelsData.previewUpstreamDiff}
|
||||
applyUpstreamOverwrite={modelsData.applyUpstreamOverwrite}
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
|
||||
@@ -121,6 +121,7 @@ const EditModelModal = (props) => {
|
||||
endpoints: '',
|
||||
name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
|
||||
status: true,
|
||||
sync_official: true,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -145,8 +146,9 @@ const EditModelModal = (props) => {
|
||||
if (!data.endpoints) {
|
||||
data.endpoints = '';
|
||||
}
|
||||
// 处理status,将数字转为布尔值
|
||||
// 处理status/sync_official,将数字转为布尔值
|
||||
data.status = data.status === 1;
|
||||
data.sync_official = (data.sync_official ?? 1) === 1;
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||
}
|
||||
@@ -193,6 +195,7 @@ const EditModelModal = (props) => {
|
||||
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
|
||||
endpoints: values.endpoints || '',
|
||||
status: values.status ? 1 : 0,
|
||||
sync_official: values.sync_official ? 1 : 0,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
@@ -505,6 +508,16 @@ const EditModelModal = (props) => {
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field='sync_official'
|
||||
label={t('参与官方同步')}
|
||||
extraText={t(
|
||||
'关闭后,此模型将不会被“同步官方”自动覆盖或创建',
|
||||
)}
|
||||
size='large'
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field='status'
|
||||
|
||||
245
web/src/components/table/models/modals/UpstreamConflictModal.jsx
Normal file
245
web/src/components/table/models/modals/UpstreamConflictModal.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Table,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Empty,
|
||||
Tag,
|
||||
Popover,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { MousePointerClick } from 'lucide-react';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const FIELD_LABELS = {
|
||||
description: '描述',
|
||||
icon: '图标',
|
||||
tags: '标签',
|
||||
vendor: '供应商',
|
||||
name_rule: '命名规则',
|
||||
status: '状态',
|
||||
};
|
||||
const FIELD_KEYS = Object.keys(FIELD_LABELS);
|
||||
|
||||
const UpstreamConflictModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
conflicts = [],
|
||||
onSubmit,
|
||||
t,
|
||||
loading = false,
|
||||
}) => {
|
||||
const [selections, setSelections] = useState({});
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const formatValue = (v) => {
|
||||
if (v === null || v === undefined) return '-';
|
||||
if (typeof v === 'string') return v || '-';
|
||||
try {
|
||||
return JSON.stringify(v, null, 2);
|
||||
} catch (_) {
|
||||
return String(v);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const init = {};
|
||||
conflicts.forEach((item) => {
|
||||
init[item.model_name] = new Set();
|
||||
});
|
||||
setSelections(init);
|
||||
} else {
|
||||
setSelections({});
|
||||
}
|
||||
}, [visible, conflicts]);
|
||||
|
||||
const toggleField = (modelName, field, checked) => {
|
||||
setSelections((prev) => {
|
||||
const next = { ...prev };
|
||||
const set = new Set(next[modelName] || []);
|
||||
if (checked) set.add(field);
|
||||
else set.delete(field);
|
||||
next[modelName] = set;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const base = [
|
||||
{
|
||||
title: t('模型'),
|
||||
dataIndex: 'model_name',
|
||||
render: (text) => <Text strong>{text}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
const cols = FIELD_KEYS.map((fieldKey) => {
|
||||
const rawLabel = FIELD_LABELS[fieldKey] || fieldKey;
|
||||
const label = t(rawLabel);
|
||||
|
||||
// 统计列头复选框状态(仅统计存在该字段冲突的行)
|
||||
const presentRows = (conflicts || []).filter((row) =>
|
||||
(row.fields || []).some((f) => f.field === fieldKey),
|
||||
);
|
||||
const selectedCount = presentRows.filter((row) =>
|
||||
selections[row.model_name]?.has(fieldKey),
|
||||
).length;
|
||||
const allCount = presentRows.length;
|
||||
if (allCount === 0) {
|
||||
return null; // 若此字段在所有行中都不存在,则不展示该列
|
||||
}
|
||||
const headerChecked = allCount > 0 && selectedCount === allCount;
|
||||
const headerIndeterminate = selectedCount > 0 && selectedCount < allCount;
|
||||
|
||||
const onHeaderChange = (e) => {
|
||||
const checked = e?.target?.checked;
|
||||
setSelections((prev) => {
|
||||
const next = { ...prev };
|
||||
(conflicts || []).forEach((row) => {
|
||||
const hasField = (row.fields || []).some(
|
||||
(f) => f.field === fieldKey,
|
||||
);
|
||||
if (!hasField) return;
|
||||
const set = new Set(next[row.model_name] || []);
|
||||
if (checked) set.add(fieldKey);
|
||||
else set.delete(fieldKey);
|
||||
next[row.model_name] = set;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
title: (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={headerChecked}
|
||||
indeterminate={headerIndeterminate}
|
||||
onChange={onHeaderChange}
|
||||
/>
|
||||
<Text>{label}</Text>
|
||||
</div>
|
||||
),
|
||||
dataIndex: fieldKey,
|
||||
render: (_, record) => {
|
||||
const f = (record.fields || []).find((x) => x.field === fieldKey);
|
||||
if (!f) return <Text type='tertiary'>-</Text>;
|
||||
const checked = selections[record.model_name]?.has(fieldKey) || false;
|
||||
return (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={(e) =>
|
||||
toggleField(record.model_name, fieldKey, e?.target?.checked)
|
||||
}
|
||||
>
|
||||
<Popover
|
||||
trigger='hover'
|
||||
position='top'
|
||||
content={
|
||||
<div className='p-2 max-w-[520px]'>
|
||||
<div className='mb-2'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('本地')}
|
||||
</Text>
|
||||
<pre className='whitespace-pre-wrap m-0'>
|
||||
{formatValue(f.local)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('官方')}
|
||||
</Text>
|
||||
<pre className='whitespace-pre-wrap m-0'>
|
||||
{formatValue(f.upstream)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag
|
||||
color='white'
|
||||
size='small'
|
||||
prefixIcon={<MousePointerClick size={14} />}
|
||||
>
|
||||
{t('点击查看差异')}
|
||||
</Tag>
|
||||
</Popover>
|
||||
</Checkbox>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return [...base, ...cols.filter(Boolean)];
|
||||
}, [t, selections, conflicts]);
|
||||
|
||||
const dataSource = conflicts.map((c) => ({
|
||||
key: c.model_name,
|
||||
model_name: c.model_name,
|
||||
fields: c.fields || [],
|
||||
}));
|
||||
|
||||
const handleOk = async () => {
|
||||
const payload = Object.entries(selections)
|
||||
.map(([modelName, set]) => ({
|
||||
model_name: modelName,
|
||||
fields: Array.from(set || []),
|
||||
}))
|
||||
.filter((x) => x.fields.length > 0);
|
||||
|
||||
if (payload.length === 0) {
|
||||
onClose?.();
|
||||
return;
|
||||
}
|
||||
const ok = await onSubmit?.(payload);
|
||||
if (ok) onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('选择要覆盖的冲突项')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
onOk={handleOk}
|
||||
confirmLoading={loading}
|
||||
okText={t('应用覆盖')}
|
||||
cancelText={t('取消')}
|
||||
width={isMobile ? '100%' : 1000}
|
||||
>
|
||||
{dataSource.length === 0 ? (
|
||||
<Empty description={t('无冲突项')} className='p-6' />
|
||||
) : (
|
||||
<>
|
||||
<div className='mb-3 text-[var(--semi-color-text-2)]'>
|
||||
{t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
|
||||
</div>
|
||||
<Table columns={columns} dataSource={dataSource} pagination={false} />
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpstreamConflictModal;
|
||||
Reference in New Issue
Block a user