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:
t0ng7u
2025-09-02 02:04:22 +08:00
parent 1f111a163a
commit fbc19abd28
10 changed files with 902 additions and 22 deletions

View File

@@ -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}
/>
</>
);
};

View File

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

View File

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

View File

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

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