From 85a12649967a364f76d7cdc8fbbc74c0d19908cf Mon Sep 17 00:00:00 2001 From: zerob13 Date: Wed, 1 Apr 2026 13:52:33 +0800 Subject: [PATCH 1/4] feat(settings): refresh provider db --- src/main/presenter/configPresenter/index.ts | 11 +- .../configPresenter/providerDbLoader.ts | 108 ++++++-- .../settings/components/DataSettings.vue | 81 ++++++ src/renderer/src/i18n/da-DK/settings.json | 12 + src/renderer/src/i18n/en-US/settings.json | 12 + src/renderer/src/i18n/fa-IR/settings.json | 12 + src/renderer/src/i18n/fr-FR/settings.json | 12 + src/renderer/src/i18n/he-IL/settings.json | 12 + src/renderer/src/i18n/ja-JP/settings.json | 12 + src/renderer/src/i18n/ko-KR/settings.json | 12 + src/renderer/src/i18n/pt-BR/settings.json | 12 + src/renderer/src/i18n/ru-RU/settings.json | 12 + src/renderer/src/i18n/zh-CN/settings.json | 12 + src/renderer/src/i18n/zh-HK/settings.json | 12 + src/renderer/src/i18n/zh-TW/settings.json | 12 + .../types/presenters/legacy.presenters.d.ts | 8 + .../configPresenter/providerDbLoader.test.ts | 260 ++++++++++++++++++ test/renderer/components/DataSettings.test.ts | 232 ++++++++++++++++ 18 files changed, 819 insertions(+), 25 deletions(-) create mode 100644 test/main/presenter/configPresenter/providerDbLoader.test.ts create mode 100644 test/renderer/components/DataSettings.test.ts diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index 6a2125ebf..22767fa29 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -16,7 +16,8 @@ import { AcpAgentState, AcpManualAgent, AcpRegistryAgent, - AcpResolvedLaunchSpec + AcpResolvedLaunchSpec, + ProviderDbRefreshResult } from '@shared/presenter' import { ProviderBatchUpdate } from '@shared/provider-operations' import { SearchEngineTemplate } from '@shared/chat' @@ -343,7 +344,9 @@ export class ConfigPresenter implements IConfigPresenter { this.initProviderModelsDir() // 初始化 Provider DB(外部聚合 JSON,本地内置为兜底) - providerDbLoader.initialize().catch(() => {}) + providerDbLoader.initialize().catch((error) => { + console.warn('[ConfigPresenter] Failed to initialize provider DB:', error) + }) // If application version is updated, update appVersion if (this.store.get('appVersion') !== this.currentAppVersion) { @@ -524,6 +527,10 @@ export class ConfigPresenter implements IConfigPresenter { return providerDbLoader.getDb() } + async refreshProviderDb(force = false): Promise { + return providerDbLoader.refreshIfNeeded(force) + } + supportsReasoningCapability(providerId: string, modelId: string): boolean { return modelCapabilities.supportsReasoning(providerId, modelId) } diff --git a/src/main/presenter/configPresenter/providerDbLoader.ts b/src/main/presenter/configPresenter/providerDbLoader.ts index bf4b98b8a..0cc742fcd 100644 --- a/src/main/presenter/configPresenter/providerDbLoader.ts +++ b/src/main/presenter/configPresenter/providerDbLoader.ts @@ -22,12 +22,20 @@ type MetaFile = { lastAttemptedAt?: number } +export type ProviderDbRefreshResult = { + status: 'updated' | 'not-modified' | 'skipped' | 'error' + lastUpdated: number | null + providersCount: number + message?: string +} + export class ProviderDbLoader { private cache: ProviderAggregate | null = null private userDataDir: string private cacheDir: string private cacheFilePath: string private metaFilePath: string + private refreshPromise: Promise | null = null constructor() { this.userDataDir = app.getPath('userData') @@ -53,8 +61,16 @@ export class ProviderDbLoader { } catch {} } - // Background refresh if needed (npm 缓存风格) - this.refreshIfNeeded().catch(() => {}) + // Always refresh once in the background on startup to pick up upstream updates. + void this.refreshIfNeeded(true) + .then((result) => { + if (result.status === 'error') { + console.warn('[ProviderDbLoader] Startup refresh failed:', result.message) + } + }) + .catch((error) => { + console.warn('[ProviderDbLoader] Startup refresh failed:', error) + }) } getDb(): ProviderAggregate | null { @@ -135,8 +151,8 @@ export class ProviderDbLoader { private getTtlHours(): number { const env = process.env.PROVIDER_DB_TTL_HOURS - const v = env ? Number(env) : 12 - return Number.isFinite(v) && v > 0 ? v : 12 + const v = env ? Number(env) : 4 + return Number.isFinite(v) && v > 0 ? v : 4 } private getProviderDbUrl(): string { @@ -149,20 +165,58 @@ export class ProviderDbLoader { return DEFAULT_PROVIDER_DB_URL } - async refreshIfNeeded(force = false): Promise { + async refreshIfNeeded(force = false): Promise { + if (this.refreshPromise) return this.refreshPromise + const meta = this.readMeta() const ttlHours = this.getTtlHours() const url = this.getProviderDbUrl() const needFirstFetch = !meta || !fs.existsSync(this.cacheFilePath) - const expired = meta ? this.now() - meta.lastUpdated > ttlHours * 3600 * 1000 : true + const freshnessTimestamp = meta?.lastAttemptedAt ?? meta?.lastUpdated ?? 0 + const expired = meta ? this.now() - freshnessTimestamp > ttlHours * 3600 * 1000 : true - if (!force && !needFirstFetch && !expired) return + if (!force && !needFirstFetch && !expired) { + return this.createResult('skipped', meta) + } - await this.fetchAndUpdate(url, meta || undefined) + this.refreshPromise = this.fetchAndUpdate(url, meta || undefined).finally(() => { + this.refreshPromise = null + }) + + return this.refreshPromise } - private async fetchAndUpdate(url: string, prevMeta?: MetaFile): Promise { + private createResult( + status: ProviderDbRefreshResult['status'], + meta?: MetaFile | null, + message?: string + ): ProviderDbRefreshResult { + const db = this.getDb() + const providersCount = Object.keys(db?.providers || {}).length + return { + status, + lastUpdated: meta?.lastUpdated ?? null, + providersCount, + ...(message ? { message } : {}) + } + } + + private createAttemptMeta( + prevMeta: MetaFile | undefined, + url: string, + now: number + ): MetaFile | null { + if (!prevMeta) return null + return { + ...prevMeta, + sourceUrl: url, + ttlHours: this.getTtlHours(), + lastAttemptedAt: now + } + } + + private async fetchAndUpdate(url: string, prevMeta?: MetaFile): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 15000) try { @@ -173,40 +227,44 @@ export class ProviderDbLoader { const now = this.now() if (res.status === 304 && prevMeta) { - // Not modified; update attempted time only - this.writeMeta({ ...prevMeta, lastAttemptedAt: now }) - return + const meta: MetaFile = { + ...prevMeta, + sourceUrl: url, + ttlHours: this.getTtlHours(), + lastAttemptedAt: now + } + this.writeMeta(meta) + return this.createResult('not-modified', meta) } if (!res.ok) { - // Keep old cache - const meta = prevMeta ? { ...prevMeta, lastAttemptedAt: now } : undefined + const meta = this.createAttemptMeta(prevMeta, url, now) if (meta) this.writeMeta(meta) - return + return this.createResult('error', meta, `Request failed with status ${res.status}`) } const text = await res.text() // Size guard (≈ 5MB) if (text.length > 5 * 1024 * 1024) { - const meta = prevMeta ? { ...prevMeta, lastAttemptedAt: now } : undefined + const meta = this.createAttemptMeta(prevMeta, url, now) if (meta) this.writeMeta(meta) - return + return this.createResult('error', meta, 'Provider DB payload exceeds size limit') } let parsed: unknown try { parsed = JSON.parse(text) } catch { - const meta = prevMeta ? { ...prevMeta, lastAttemptedAt: now } : undefined + const meta = this.createAttemptMeta(prevMeta, url, now) if (meta) this.writeMeta(meta) - return + return this.createResult('error', meta, 'Provider DB payload is not valid JSON') } const sanitized = sanitizeAggregate(parsed) if (!sanitized) { - const meta = prevMeta ? { ...prevMeta, lastAttemptedAt: now } : undefined + const meta = this.createAttemptMeta(prevMeta, url, now) if (meta) this.writeMeta(meta) - return + return this.createResult('error', meta, 'Provider DB payload failed validation') } const etag = res.headers.get('etag') || undefined @@ -229,8 +287,12 @@ export class ProviderDbLoader { lastUpdated: meta.lastUpdated }) } catch {} - } catch { - // ignore + return this.createResult('updated', meta) + } catch (error) { + const meta = this.createAttemptMeta(prevMeta, url, this.now()) + if (meta) this.writeMeta(meta) + const message = error instanceof Error ? error.message : 'Unknown provider DB refresh error' + return this.createResult('error', meta, message) } finally { clearTimeout(timeout) } diff --git a/src/renderer/settings/components/DataSettings.vue b/src/renderer/settings/components/DataSettings.vue index 61842c1db..90505fd92 100644 --- a/src/renderer/settings/components/DataSettings.vue +++ b/src/renderer/settings/components/DataSettings.vue @@ -147,6 +147,40 @@ +
+
+ +
+
{{ t('settings.data.modelConfigUpdate.title') }}
+

+ {{ t('settings.data.modelConfigUpdate.description') }} +

+
+
+ +
+ + + @@ -372,6 +406,7 @@ const languageStore = useLanguageStore() const syncStore = useSyncStore() const devicePresenter = usePresenter('devicePresenter') const yoBrowserPresenter = usePresenter('yoBrowserPresenter') +const configPresenter = usePresenter('configPresenter') const { backups: backupsRef } = storeToRefs(syncStore) const { toast } = useToast() @@ -382,6 +417,7 @@ const selectedBackup = ref('') const isResetDialogOpen = ref(false) const resetType = ref<'chat' | 'knowledge' | 'config' | 'all'>('chat') const isResetting = ref(false) +const isUpdatingModelConfig = ref(false) const isClearingSandbox = ref(false) const isClearSandboxDialogOpen = ref(false) @@ -462,6 +498,51 @@ const handleBackup = async () => { }) } +const handleRefreshProviderDb = async () => { + if (isUpdatingModelConfig.value) return + + isUpdatingModelConfig.value = true + try { + const result = await configPresenter.refreshProviderDb(true) + + if (!result || result.status === 'error') { + console.error('Failed to refresh provider DB:', result?.message) + toast({ + title: t('settings.data.modelConfigUpdate.failedTitle'), + description: t('settings.data.modelConfigUpdate.failedDescription'), + variant: 'destructive', + duration: 4000 + }) + return + } + + const isUpToDate = result.status === 'not-modified' || result.status === 'skipped' + toast({ + title: t( + isUpToDate + ? 'settings.data.modelConfigUpdate.upToDateTitle' + : 'settings.data.modelConfigUpdate.updatedTitle' + ), + description: t( + isUpToDate + ? 'settings.data.modelConfigUpdate.upToDateDescription' + : 'settings.data.modelConfigUpdate.updatedDescription' + ), + duration: 4000 + }) + } catch (error) { + console.error('Failed to refresh provider DB:', error) + toast({ + title: t('settings.data.modelConfigUpdate.failedTitle'), + description: t('settings.data.modelConfigUpdate.failedDescription'), + variant: 'destructive', + duration: 4000 + }) + } finally { + isUpdatingModelConfig.value = false + } +} + // 关闭导入对话框 const closeImportDialog = () => { isImportDialogOpen.value = false diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 27d2c0b64..d752b71e1 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -117,6 +117,18 @@ "backupSuccessTitle": "Backup lykkedes", "backupSuccessMessage": "Data blev sikkerhedskopieret" }, + "modelConfigUpdate": { + "title": "Opdater modelkonfiguration", + "description": "Tving en opdatering af provider.json fra upstream for at hente de nyeste modelkapaciteter og standardindstillinger.", + "button": "Opdater nu", + "updating": "Opdaterer...", + "updatedTitle": "Modelkonfiguration opdateret", + "updatedDescription": "De seneste providerdata er blevet hentet.", + "upToDateTitle": "Allerede opdateret", + "upToDateDescription": "Den lokale modelkonfiguration bruger allerede de nyeste providerdata.", + "failedTitle": "Opdatering mislykkedes", + "failedDescription": "Modelkonfigurationen kunne ikke opdateres lige nu. Prøv igen senere." + }, "yoBrowser": { "clearButton": "Ryd YoBrowser-data", "clearFailedDescription": "Prøv venligst igen, eller tjek logfilerne for at få flere oplysninger.", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index fc0f76266..64032f121 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -233,6 +233,18 @@ "backupSuccessTitle": "Backup successful", "backupSuccessMessage": "Data backed up successfully" }, + "modelConfigUpdate": { + "title": "Update Model Config", + "description": "Force refresh provider.json from upstream to update model capabilities and default model settings.", + "button": "Update Now", + "updating": "Updating...", + "updatedTitle": "Model config updated", + "updatedDescription": "The latest provider metadata has been refreshed.", + "upToDateTitle": "Already up to date", + "upToDateDescription": "Your local model config is already using the latest provider metadata.", + "failedTitle": "Update failed", + "failedDescription": "Unable to refresh model config right now. Please try again later." + }, "yoBrowser": { "clearButton": "Clear YoBrowser data", "clearFailedDescription": "Please try again or check the logs for more information.", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index eafd4cde8..9665f52f0 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -171,6 +171,18 @@ "backupSuccessTitle": "پشتیبان گیری با موفقیت انجام شد", "backupSuccessMessage": "داده ها با موفقیت پشتیبان گیری شدند" }, + "modelConfigUpdate": { + "title": "به‌روزرسانی پیکربندی مدل", + "description": "برای به‌روزرسانی قابلیت‌های مدل و تنظیمات پیش‌فرض، فایل provider.json را از مبدأ به‌صورت اجباری تازه‌سازی می‌کند.", + "button": "اکنون به‌روزرسانی کن", + "updating": "در حال به‌روزرسانی...", + "updatedTitle": "پیکربندی مدل به‌روز شد", + "updatedDescription": "آخرین فرادادهٔ ارائه‌دهنده دریافت شد.", + "upToDateTitle": "از قبل به‌روز است", + "upToDateDescription": "پیکربندی محلی مدل همین حالا هم از تازه‌ترین فرادادهٔ ارائه‌دهنده استفاده می‌کند.", + "failedTitle": "به‌روزرسانی ناموفق بود", + "failedDescription": "فعلاً امکان به‌روزرسانی پیکربندی مدل وجود ندارد. کمی بعد دوباره تلاش کنید." + }, "yoBrowser": { "clearButton": "داده های YoBrowser را پاک کنید", "clearFailedDescription": "لطفاً دوباره امتحان کنید یا برای اطلاعات بیشتر گزارش‌ها را بررسی کنید.", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 27c6b923f..d93dcdc4d 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -171,6 +171,18 @@ "backupSuccessTitle": "Sauvegarde réussie", "backupSuccessMessage": "Données sauvegardées avec succès" }, + "modelConfigUpdate": { + "title": "Mettre à jour la config des modèles", + "description": "Force la mise à jour de provider.json depuis la source afin d’actualiser les capacités des modèles et les réglages par défaut.", + "button": "Mettre à jour", + "updating": "Mise à jour...", + "updatedTitle": "Configuration des modèles mise à jour", + "updatedDescription": "Les dernières métadonnées des fournisseurs ont bien été récupérées.", + "upToDateTitle": "Déjà à jour", + "upToDateDescription": "La configuration locale utilise déjà les métadonnées fournisseur les plus récentes.", + "failedTitle": "Mise à jour impossible", + "failedDescription": "Impossible d’actualiser la configuration des modèles pour le moment. Réessayez plus tard." + }, "yoBrowser": { "clearButton": "Effacer les données de YoBrowser", "clearFailedDescription": "Veuillez réessayer ou consulter les journaux pour plus d'informations.", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 3162dbc39..ed6049296 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -171,6 +171,18 @@ "backupSuccessTitle": "גיבוי הושלם בהצלחה", "backupSuccessMessage": "הנתונים גובו בהצלחה" }, + "modelConfigUpdate": { + "title": "עדכון הגדרות המודלים", + "description": "מרענן בכפייה את provider.json מהמקור כדי לעדכן יכולות מודל והגדרות ברירת מחדל.", + "button": "עדכן עכשיו", + "updating": "מעדכן...", + "updatedTitle": "הגדרות המודלים עודכנו", + "updatedDescription": "מטא־הנתונים העדכניים של הספקים נטענו בהצלחה.", + "upToDateTitle": "כבר מעודכן", + "upToDateDescription": "הגדרות המודלים המקומיות כבר משתמשות במטא־הנתונים העדכניים ביותר של הספקים.", + "failedTitle": "העדכון נכשל", + "failedDescription": "כרגע אי אפשר לעדכן את הגדרות המודלים. נסה שוב מאוחר יותר." + }, "yoBrowser": { "clearButton": "נקה את נתוני YoBrowser", "clearFailedDescription": "אנא נסה שוב או בדוק את היומנים לקבלת מידע נוסף.", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index 9133bb0a8..082ad073e 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -171,6 +171,18 @@ "backupSuccessTitle": "バックアップ成功", "backupSuccessMessage": "データは正常にバックアップされました" }, + "modelConfigUpdate": { + "title": "モデル設定を更新", + "description": "上流の provider.json を強制的に更新し、モデル機能と既定設定を最新の状態にします。", + "button": "今すぐ更新", + "updating": "更新中...", + "updatedTitle": "モデル設定を更新しました", + "updatedDescription": "プロバイダーの最新メタデータを取得しました。", + "upToDateTitle": "すでに最新です", + "upToDateDescription": "ローカルのモデル設定は、すでに最新のプロバイダーメタデータを使用しています。", + "failedTitle": "更新に失敗しました", + "failedDescription": "現在モデル設定を更新できません。しばらくしてからもう一度お試しください。" + }, "yoBrowser": { "clearButton": "YoBrowser データをクリアする", "clearFailedDescription": "もう一度試すか、ログで詳細を確認してください。", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index ee863133e..7bc573a68 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -171,6 +171,18 @@ "backupSuccessTitle": "백업 성공", "backupSuccessMessage": "데이터가 성공적으로 백업되었습니다." }, + "modelConfigUpdate": { + "title": "모델 설정 업데이트", + "description": "업스트림 provider.json을 강제로 새로고침해 모델 기능과 기본 설정을 최신 상태로 맞춥니다.", + "button": "지금 업데이트", + "updating": "업데이트 중...", + "updatedTitle": "모델 설정을 업데이트했습니다", + "updatedDescription": "최신 제공자 메타데이터를 가져왔습니다.", + "upToDateTitle": "이미 최신 상태입니다", + "upToDateDescription": "로컬 모델 설정이 이미 최신 제공자 메타데이터를 사용하고 있습니다.", + "failedTitle": "업데이트에 실패했습니다", + "failedDescription": "지금은 모델 설정을 업데이트할 수 없습니다. 잠시 후 다시 시도하세요." + }, "yoBrowser": { "clearButton": "YoBrowser 데이터 지우기", "clearFailedDescription": "자세한 내용은 다시 시도하거나 로그를 확인하세요.", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 2c30b4cc2..d2de985cb 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -171,6 +171,18 @@ "backupSuccessTitle": "Backup bem-sucedido", "backupSuccessMessage": "Dados salvos em backup com sucesso" }, + "modelConfigUpdate": { + "title": "Atualizar configuração dos modelos", + "description": "Força a atualização do provider.json para trazer as capacidades mais recentes dos modelos e as configurações padrão.", + "button": "Atualizar agora", + "updating": "Atualizando...", + "updatedTitle": "Configuração dos modelos atualizada", + "updatedDescription": "Os metadados mais recentes dos provedores foram atualizados.", + "upToDateTitle": "Já está atualizado", + "upToDateDescription": "A configuração local dos modelos já está usando os metadados mais recentes dos provedores.", + "failedTitle": "Falha ao atualizar", + "failedDescription": "Não foi possível atualizar a configuração dos modelos agora. Tente novamente mais tarde." + }, "yoBrowser": { "clearButton": "Limpar dados do YoBrowser", "clearFailedDescription": "Tente novamente ou verifique os registros para obter mais informações.", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index b1692366f..bfbba473d 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -171,6 +171,18 @@ "backupSuccessTitle": "Резервное копирование выполнено успешно", "backupSuccessMessage": "Резервное копирование данных успешно выполнено" }, + "modelConfigUpdate": { + "title": "Обновить конфигурацию моделей", + "description": "Принудительно обновляет provider.json из источника, чтобы подтянуть актуальные возможности моделей и настройки по умолчанию.", + "button": "Обновить сейчас", + "updating": "Обновление...", + "updatedTitle": "Конфигурация моделей обновлена", + "updatedDescription": "Получены актуальные метаданные провайдеров.", + "upToDateTitle": "Уже актуально", + "upToDateDescription": "Локальная конфигурация моделей уже использует актуальные метаданные провайдеров.", + "failedTitle": "Не удалось обновить", + "failedDescription": "Сейчас не удалось обновить конфигурацию моделей. Попробуйте позже." + }, "yoBrowser": { "clearButton": "Очистить данные YoBrowser", "clearFailedDescription": "Повторите попытку или проверьте журналы для получения дополнительной информации.", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index d6fb802bc..e0c7e12aa 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -233,6 +233,18 @@ "backupSuccessTitle": "备份成功", "backupSuccessMessage": "成功备份了数据" }, + "modelConfigUpdate": { + "title": "更新模型配置", + "description": "从上游强制刷新 provider.json,用最新的服务商元数据更新模型能力与默认配置。", + "button": "立即更新", + "updating": "更新中...", + "updatedTitle": "模型配置已更新", + "updatedDescription": "已刷新到最新的服务商元数据。", + "upToDateTitle": "已是最新", + "upToDateDescription": "当前本地模型配置已使用最新的服务商元数据。", + "failedTitle": "更新失败", + "failedDescription": "暂时无法刷新模型配置,请稍后重试。" + }, "yoBrowser": { "title": "YoBrowser 沙盒", "description": "独立的浏览器沙盒,不与主应用共享 Cookie 和本地存储。可在此一键清空 YoBrowser 数据。", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 179ba715d..169bf6afa 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -171,6 +171,18 @@ "backupSuccessTitle": "備份成功", "backupSuccessMessage": "成功備份了數據" }, + "modelConfigUpdate": { + "title": "更新模型設定", + "description": "從上游強制重新整理 provider.json,以最新的服務商中繼資料更新模型能力與預設設定。", + "button": "立即更新", + "updating": "更新中...", + "updatedTitle": "模型設定已更新", + "updatedDescription": "已重新整理到最新的服務商中繼資料。", + "upToDateTitle": "已是最新", + "upToDateDescription": "目前本機模型設定已使用最新的服務商中繼資料。", + "failedTitle": "更新失敗", + "failedDescription": "暫時未能重新整理模型設定,請稍後再試。" + }, "yoBrowser": { "clearButton": "清空 YoBrowser 數據", "clearFailedDescription": "請重試或查看日誌獲取更多信息。", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 71059bd1d..c003e33d6 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -171,6 +171,18 @@ "backupSuccessTitle": "備份成功", "backupSuccessMessage": "成功備份了數據" }, + "modelConfigUpdate": { + "title": "更新模型設定", + "description": "從上游強制重新整理 provider.json,以最新的服務商中繼資料更新模型能力與預設設定。", + "button": "立即更新", + "updating": "更新中...", + "updatedTitle": "模型設定已更新", + "updatedDescription": "已重新整理為最新的服務商中繼資料。", + "upToDateTitle": "已是最新", + "upToDateDescription": "目前本機模型設定已使用最新的服務商中繼資料。", + "failedTitle": "更新失敗", + "failedDescription": "目前無法重新整理模型設定,請稍後再試。" + }, "yoBrowser": { "clearButton": "清空 YoBrowser 數據", "clearFailedDescription": "請重試或查看日誌獲取更多信息。", diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index d0c4cec32..7031cfad3 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -499,6 +499,13 @@ export interface INotificationPresenter { import type { ReasoningPortrait } from '../model-db' +export type ProviderDbRefreshResult = { + status: 'updated' | 'not-modified' | 'skipped' | 'error' + lastUpdated: number | null + providersCount: number + message?: string +} + export interface IConfigPresenter { getSetting(key: string): T | undefined setSetting(key: string, value: T): void @@ -733,6 +740,7 @@ export interface IConfigPresenter { setAutoDetectNpmRegistry?(enabled: boolean): void clearNpmRegistryCache?(): void getProviderDb(): { providers: Record } | null + refreshProviderDb(force?: boolean): Promise // Default model settings getDefaultModel(): { providerId: string; modelId: string } | undefined diff --git a/test/main/presenter/configPresenter/providerDbLoader.test.ts b/test/main/presenter/configPresenter/providerDbLoader.test.ts new file mode 100644 index 000000000..cc9eb48b0 --- /dev/null +++ b/test/main/presenter/configPresenter/providerDbLoader.test.ts @@ -0,0 +1,260 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + getPath: vi.fn(), + getAppPath: vi.fn(), + send: vi.fn() +})) + +vi.mock('fs', async () => { + const actual = await vi.importActual('node:fs') + return { + __esModule: true, + ...actual, + default: actual + } +}) + +vi.mock('electron', () => ({ + app: { + getPath: state.getPath, + getAppPath: state.getAppPath + } +})) + +vi.mock('@/eventbus', () => ({ + eventBus: { + send: state.send + }, + SendTarget: { + ALL_WINDOWS: 'ALL_WINDOWS' + } +})) + +describe('ProviderDbLoader', () => { + let tempRoot: string + let appRoot: string + let userDataRoot: string + + const importLoader = async () => { + const mod = await import('../../../../src/main/presenter/configPresenter/providerDbLoader') + return mod.ProviderDbLoader + } + + const getCacheDir = () => path.join(userDataRoot, 'provider-db') + const getCacheFile = () => path.join(getCacheDir(), 'providers.json') + const getMetaFile = () => path.join(getCacheDir(), 'meta.json') + + const createAggregate = (providerIds: string[]) => ({ + providers: Object.fromEntries( + providerIds.map((providerId) => [ + providerId, + { + id: providerId, + name: providerId, + models: [ + { + id: `${providerId}-model` + } + ] + } + ]) + ) + }) + + const writeBuiltInDb = (aggregate: Record) => { + const modelDbDir = path.join(appRoot, 'resources', 'model-db') + fs.mkdirSync(modelDbDir, { recursive: true }) + fs.writeFileSync(path.join(modelDbDir, 'providers.json'), JSON.stringify(aggregate), 'utf-8') + } + + const writeCachedDb = (aggregate: Record) => { + fs.mkdirSync(getCacheDir(), { recursive: true }) + fs.writeFileSync(getCacheFile(), JSON.stringify(aggregate), 'utf-8') + } + + const writeMeta = (meta: { + sourceUrl: string + etag?: string + lastUpdated: number + ttlHours: number + lastAttemptedAt?: number + }) => { + fs.mkdirSync(getCacheDir(), { recursive: true }) + fs.writeFileSync(getMetaFile(), JSON.stringify(meta), 'utf-8') + } + + const readMeta = () => JSON.parse(fs.readFileSync(getMetaFile(), 'utf-8')) + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'deepchat-provider-db-')) + appRoot = path.join(tempRoot, 'app-root') + userDataRoot = path.join(tempRoot, 'user-data') + fs.mkdirSync(appRoot, { recursive: true }) + fs.mkdirSync(userDataRoot, { recursive: true }) + + state.getPath.mockImplementation((name: string) => { + if (name === 'userData') return userDataRoot + return userDataRoot + }) + state.getAppPath.mockReturnValue(appRoot) + state.send.mockReset() + vi.unstubAllGlobals() + delete process.env.PROVIDER_DB_TTL_HOURS + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + delete process.env.PROVIDER_DB_TTL_HOURS + fs.rmSync(tempRoot, { recursive: true, force: true }) + }) + + it('initializes from cache and still triggers a startup refresh when cache is fresh', async () => { + writeBuiltInDb(createAggregate(['builtin'])) + writeCachedDb(createAggregate(['openai'])) + const now = Date.now() + writeMeta({ + sourceUrl: 'https://example.com/provider-db.json', + etag: '"etag-1"', + lastUpdated: now, + lastAttemptedAt: now, + ttlHours: 4 + }) + + const fetchMock = vi.fn().mockResolvedValue({ + status: 304, + ok: false, + headers: { + get: vi.fn().mockReturnValue('"etag-1"') + } + }) + vi.stubGlobal('fetch', fetchMock) + + const ProviderDbLoader = await importLoader() + const loader = new ProviderDbLoader() + + await loader.initialize() + + expect(loader.getDb()?.providers).toHaveProperty('openai') + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(state.send).toHaveBeenCalledWith('provider-db:loaded', 'ALL_WINDOWS', { + providersCount: 1 + }) + }) + + it('skips non-forced refreshes within the default 4-hour TTL', async () => { + writeCachedDb(createAggregate(['openai'])) + const now = Date.now() + writeMeta({ + sourceUrl: 'https://example.com/provider-db.json', + lastUpdated: now - 5_000, + lastAttemptedAt: now - 5_000, + ttlHours: 4 + }) + + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const ProviderDbLoader = await importLoader() + const loader = new ProviderDbLoader() + + const result = await loader.refreshIfNeeded(false) + + expect(result.status).toBe('skipped') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('treats 304 responses as fresh and avoids another fetch inside the TTL window', async () => { + writeCachedDb(createAggregate(['openai'])) + const staleTimestamp = Date.now() - 10 * 3600 * 1000 + writeMeta({ + sourceUrl: 'https://example.com/provider-db.json', + etag: '"etag-2"', + lastUpdated: staleTimestamp, + lastAttemptedAt: staleTimestamp, + ttlHours: 4 + }) + + const fetchMock = vi.fn().mockResolvedValue({ + status: 304, + ok: false, + headers: { + get: vi.fn().mockReturnValue('"etag-2"') + } + }) + vi.stubGlobal('fetch', fetchMock) + + const ProviderDbLoader = await importLoader() + const loader = new ProviderDbLoader() + + const result = await loader.refreshIfNeeded(true) + + expect(result.status).toBe('not-modified') + expect(result.lastUpdated).toBe(staleTimestamp) + + const meta = readMeta() + expect(meta.lastUpdated).toBe(staleTimestamp) + expect(meta.lastAttemptedAt).toBeGreaterThan(staleTimestamp) + + fetchMock.mockClear() + const nextResult = await loader.refreshIfNeeded(false) + expect(nextResult.status).toBe('skipped') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('writes refreshed provider data and emits an update event on success', async () => { + writeCachedDb(createAggregate(['openai'])) + + const refreshedAggregate = createAggregate(['openai', 'anthropic']) + const fetchMock = vi.fn().mockResolvedValue({ + status: 200, + ok: true, + headers: { + get: vi.fn().mockReturnValue('"etag-3"') + }, + text: vi.fn().mockResolvedValue(JSON.stringify(refreshedAggregate)) + }) + vi.stubGlobal('fetch', fetchMock) + + const ProviderDbLoader = await importLoader() + const loader = new ProviderDbLoader() + + const result = await loader.refreshIfNeeded(true) + + expect(result.status).toBe('updated') + expect(result.providersCount).toBe(2) + expect(loader.getDb()?.providers).toHaveProperty('anthropic') + expect(state.send).toHaveBeenCalledWith('provider-db:updated', 'ALL_WINDOWS', { + providersCount: 2, + lastUpdated: expect.any(Number) + }) + }) + + it('returns an error result and preserves the existing cache when refresh fails', async () => { + const cachedAggregate = createAggregate(['openai']) + writeCachedDb(cachedAggregate) + const now = Date.now() - 10 * 3600 * 1000 + writeMeta({ + sourceUrl: 'https://example.com/provider-db.json', + lastUpdated: now, + lastAttemptedAt: now, + ttlHours: 4 + }) + + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))) + + const ProviderDbLoader = await importLoader() + const loader = new ProviderDbLoader() + + const result = await loader.refreshIfNeeded(true) + + expect(result.status).toBe('error') + expect(result.message).toBe('network down') + expect(JSON.parse(fs.readFileSync(getCacheFile(), 'utf-8'))).toEqual(cachedAggregate) + expect(readMeta().lastAttemptedAt).toBeGreaterThan(now) + }) +}) diff --git a/test/renderer/components/DataSettings.test.ts b/test/renderer/components/DataSettings.test.ts new file mode 100644 index 000000000..d715e2fe8 --- /dev/null +++ b/test/renderer/components/DataSettings.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, nextTick, reactive } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' + +const buttonStub = defineComponent({ + name: 'Button', + props: { + disabled: { + type: Boolean, + default: false + } + }, + emits: ['click'], + template: '' +}) + +const passthroughStub = (name: string) => + defineComponent({ + name, + template: '
' + }) + +const setup = async () => { + vi.resetModules() + + const toast = vi.fn() + const syncStore = reactive({ + syncEnabled: true, + syncFolderPath: '/tmp/deepchat-sync', + lastSyncTime: 0, + isBackingUp: false, + isImporting: false, + importResult: null, + backups: [] as Array<{ fileName: string; createdAt: number; size: number }>, + initialize: vi.fn().mockResolvedValue(undefined), + selectSyncFolder: vi.fn(), + openSyncFolder: vi.fn(), + refreshBackups: vi.fn().mockResolvedValue(undefined), + startBackup: vi.fn().mockResolvedValue(null), + importData: vi.fn().mockResolvedValue(null), + clearImportResult: vi.fn(), + setSyncEnabled: vi.fn(), + setSyncFolderPath: vi.fn() + }) + + const presenterMocks = { + configPresenter: { + refreshProviderDb: vi.fn().mockResolvedValue({ + status: 'updated', + lastUpdated: Date.now(), + providersCount: 1 + }) + }, + devicePresenter: { + resetDataByType: vi.fn().mockResolvedValue(undefined) + }, + yoBrowserPresenter: { + clearSandboxData: vi.fn().mockResolvedValue(undefined) + } + } + + vi.doMock('@/stores/sync', () => ({ + useSyncStore: () => syncStore + })) + vi.doMock('@/stores/language', () => ({ + useLanguageStore: () => ({ + dir: 'ltr' + }) + })) + vi.doMock('@/composables/usePresenter', () => ({ + usePresenter: (name: keyof typeof presenterMocks) => presenterMocks[name] + })) + vi.doMock('@/components/use-toast', () => ({ + useToast: () => ({ + toast + }) + })) + vi.doMock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key + }) + })) + vi.doMock('pinia', async () => { + const vue = await vi.importActual('vue') + return { + storeToRefs: () => ({ + backups: vue.toRef(syncStore, 'backups') + }) + } + }) + + const DataSettings = (await import('../../../src/renderer/settings/components/DataSettings.vue')) + .default + + const wrapper = mount(DataSettings, { + global: { + stubs: { + ScrollArea: passthroughStub('ScrollArea'), + Icon: true, + Dialog: passthroughStub('Dialog'), + DialogContent: passthroughStub('DialogContent'), + DialogDescription: passthroughStub('DialogDescription'), + DialogFooter: passthroughStub('DialogFooter'), + DialogHeader: passthroughStub('DialogHeader'), + DialogTitle: passthroughStub('DialogTitle'), + DialogTrigger: passthroughStub('DialogTrigger'), + AlertDialog: passthroughStub('AlertDialog'), + AlertDialogAction: passthroughStub('AlertDialogAction'), + AlertDialogCancel: passthroughStub('AlertDialogCancel'), + AlertDialogContent: passthroughStub('AlertDialogContent'), + AlertDialogDescription: passthroughStub('AlertDialogDescription'), + AlertDialogFooter: passthroughStub('AlertDialogFooter'), + AlertDialogHeader: passthroughStub('AlertDialogHeader'), + AlertDialogTitle: passthroughStub('AlertDialogTitle'), + AlertDialogTrigger: passthroughStub('AlertDialogTrigger'), + Button: buttonStub, + Input: defineComponent({ name: 'Input', template: '' }), + Switch: defineComponent({ name: 'Switch', template: '' }), + RadioGroup: passthroughStub('RadioGroup'), + RadioGroupItem: passthroughStub('RadioGroupItem'), + Label: passthroughStub('Label'), + Separator: passthroughStub('Separator'), + Select: passthroughStub('Select'), + SelectContent: passthroughStub('SelectContent'), + SelectItem: passthroughStub('SelectItem'), + SelectTrigger: passthroughStub('SelectTrigger'), + SelectValue: passthroughStub('SelectValue') + } + } + }) + + await flushPromises() + + return { + wrapper, + toast, + syncStore, + presenterMocks + } +} + +const findRefreshButton = (wrapper: ReturnType) => { + const button = wrapper + .findAll('button') + .find((item) => item.text().includes('settings.data.modelConfigUpdate')) + + if (!button) { + throw new Error('Refresh provider DB button not found') + } + + return button +} + +describe('DataSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('calls refreshProviderDb, shows loading state, then shows an updated toast', async () => { + const { wrapper, toast, presenterMocks } = await setup() + + let resolveRefresh: + | ((value: { status: string; lastUpdated: number; providersCount: number }) => void) + | null = null + presenterMocks.configPresenter.refreshProviderDb.mockReturnValueOnce( + new Promise((resolve) => { + resolveRefresh = resolve + }) + ) + + await findRefreshButton(wrapper).trigger('click') + await nextTick() + + const loadingButton = findRefreshButton(wrapper) + expect(loadingButton.attributes('disabled')).toBeDefined() + expect(loadingButton.text()).toContain('settings.data.modelConfigUpdate.updating') + + resolveRefresh?.({ + status: 'updated', + lastUpdated: Date.now(), + providersCount: 3 + }) + await flushPromises() + + expect(presenterMocks.configPresenter.refreshProviderDb).toHaveBeenCalledWith(true) + expect(toast).toHaveBeenCalledWith({ + title: 'settings.data.modelConfigUpdate.updatedTitle', + description: 'settings.data.modelConfigUpdate.updatedDescription', + duration: 4000 + }) + }) + + it('shows an up-to-date toast when upstream metadata has not changed', async () => { + const { wrapper, toast, presenterMocks } = await setup() + + presenterMocks.configPresenter.refreshProviderDb.mockResolvedValueOnce({ + status: 'not-modified', + lastUpdated: Date.now(), + providersCount: 2 + }) + + await findRefreshButton(wrapper).trigger('click') + await flushPromises() + + expect(toast).toHaveBeenCalledWith({ + title: 'settings.data.modelConfigUpdate.upToDateTitle', + description: 'settings.data.modelConfigUpdate.upToDateDescription', + duration: 4000 + }) + }) + + it('shows a destructive toast when refreshing provider metadata fails', async () => { + const { wrapper, toast, presenterMocks } = await setup() + + presenterMocks.configPresenter.refreshProviderDb.mockResolvedValueOnce({ + status: 'error', + lastUpdated: null, + providersCount: 1, + message: 'network down' + }) + + await findRefreshButton(wrapper).trigger('click') + await flushPromises() + + expect(toast).toHaveBeenCalledWith({ + title: 'settings.data.modelConfigUpdate.failedTitle', + description: 'settings.data.modelConfigUpdate.failedDescription', + variant: 'destructive', + duration: 4000 + }) + }) +}) From 73340270612f89d2f27a2bd0fc94f3581708152a Mon Sep 17 00:00:00 2001 From: zerob13 Date: Wed, 1 Apr 2026 15:35:13 +0800 Subject: [PATCH 2/4] fix(chat): use command icon for slash suggestions --- .../chat/mentions/SuggestionList.vue | 13 ++++++++++- .../components/SuggestionList.test.ts | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/chat/mentions/SuggestionList.vue b/src/renderer/src/components/chat/mentions/SuggestionList.vue index 9fd9946e0..7ca37d562 100644 --- a/src/renderer/src/components/chat/mentions/SuggestionList.vue +++ b/src/renderer/src/components/chat/mentions/SuggestionList.vue @@ -10,7 +10,17 @@ @click="selectIndex(index)" >
- {{ categoryTag(item.category) }} + + + {{ categoryTag(item.category) }} +
{{ item.label }}
@@ -26,6 +36,7 @@