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

@@ -95,6 +95,8 @@ export const useModelsData = () => {
const [showAddVendor, setShowAddVendor] = useState(false);
const [showEditVendor, setShowEditVendor] = useState(false);
const [editingVendor, setEditingVendor] = useState({ id: undefined });
const [syncing, setSyncing] = useState(false);
const [previewing, setPreviewing] = useState(false);
const vendorMap = useMemo(() => {
const map = {};
@@ -163,6 +165,81 @@ export const useModelsData = () => {
await loadModels(page, pageSize);
};
// Sync upstream models/vendors for missing models only
const syncUpstream = async () => {
setSyncing(true);
try {
const res = await API.post('/api/models/sync_upstream');
const { success, message, data } = res.data || {};
if (success) {
const createdModels = data?.created_models || 0;
const createdVendors = data?.created_vendors || 0;
const skipped = (data?.skipped_models || []).length || 0;
showSuccess(
t(
`已同步:新增 ${createdModels} 模型,新增 ${createdVendors} 供应商,跳过 ${skipped}`,
),
);
await loadVendors();
await refresh();
} else {
showError(message || t('同步失败'));
}
} catch (e) {
showError(t('同步失败'));
}
setSyncing(false);
};
// Preview upstream differences
const previewUpstreamDiff = async () => {
setPreviewing(true);
try {
const res = await API.get('/api/models/sync_upstream/preview');
const { success, message, data } = res.data || {};
if (success) {
return data || { missing: [], conflicts: [] };
}
showError(message || t('预览失败'));
return { missing: [], conflicts: [] };
} catch (e) {
showError(t('预览失败'));
return { missing: [], conflicts: [] };
} finally {
setPreviewing(false);
}
};
// Apply selected overwrite
const applyUpstreamOverwrite = async (overwrite = []) => {
setSyncing(true);
try {
const res = await API.post('/api/models/sync_upstream', { overwrite });
const { success, message, data } = res.data || {};
if (success) {
const createdModels = data?.created_models || 0;
const updatedModels = data?.updated_models || 0;
const createdVendors = data?.created_vendors || 0;
const skipped = (data?.skipped_models || []).length || 0;
showSuccess(
t(
`完成:新增 ${createdModels} 模型,更新 ${updatedModels} 模型,新增 ${createdVendors} 供应商,跳过 ${skipped}`,
),
);
await loadVendors();
await refresh();
return true;
}
showError(message || t('同步失败'));
return false;
} catch (e) {
showError(t('同步失败'));
return false;
} finally {
setSyncing(false);
}
};
// Search models with keyword and vendor
const searchModels = async () => {
const { searchKeyword = '', searchVendor = '' } = getFormValues();
@@ -398,5 +475,12 @@ export const useModelsData = () => {
// Translation
t,
// Upstream sync
syncing,
previewing,
syncUpstream,
previewUpstreamDiff,
applyUpstreamOverwrite,
};
};