From 4e1a681ed630ba4f177494fee1d7036a89105082 Mon Sep 17 00:00:00 2001 From: xd-bobo Date: Thu, 10 Sep 2026 12:25:53 +0800 Subject: [PATCH 1/2] feat(models): consume published catalog defaults and preserve user overrides Signed-off-by: xd-bobo --- .../__tests__/activeCatalogRevision.test.ts | 24 +++ .../__tests__/model-memory-reset.test.ts | 31 ++++ .../providerCatalogRealmReload.test.ts | 2 +- .../src/main/maker-host/active-catalog.ts | 30 ++-- .../createDesktopProviderService.ts | 16 +- .../src/main/maker-host/model-memory-reset.ts | 37 ++++ apps/desktop/src/main/maker-ipc/register.ts | 22 ++- apps/desktop/src/preload/preload.ts | 3 +- apps/desktop/src/renderer/App.tsx | 13 +- .../__tests__/deviceLinkModelMirror.test.ts | 19 ++ .../components/new-chat/ChatInput.tsx | 8 +- .../components/new-chat/ModelSelector.tsx | 7 +- .../new-chat/unifiedModelSelection.ts | 4 +- .../new-chat/useUnifiedRowActions.ts | 39 ++-- .../settings/ModelAdvancedDrawer.tsx | 6 + .../features/cc-agent/CCAgentSessionView.tsx | 6 +- .../features/cc-agent/CreateWorkerPopover.tsx | 2 +- .../features/cc-agent/NewMakerDraftRoute.tsx | 10 +- .../src/renderer/i18n/locales/en/common.json | 5 +- .../src/renderer/i18n/locales/ja/common.json | 5 +- .../src/renderer/i18n/locales/ko/common.json | 5 +- .../renderer/i18n/locales/zh-CN/common.json | 5 +- .../renderer/i18n/locales/zh-TW/common.json | 5 +- .../renderer/state/deviceLinkModelMirror.ts | 15 +- .../src/renderer/state/providerModelMemory.ts | 10 ++ apps/desktop/src/renderer/vite-env.d.ts | 4 + apps/mobile/app/sessions/[sessionId].tsx | 1 + .../src/__tests__/draftModelMemory.test.ts | 18 ++ .../src/__tests__/sessionModelMirror.test.ts | 13 ++ .../src/device-link/mobileMakerTransport.ts | 2 + apps/mobile/src/i18n/locales/en/models.json | 5 +- apps/mobile/src/i18n/locales/ja/models.json | 5 +- apps/mobile/src/i18n/locales/ko/models.json | 5 +- .../mobile/src/i18n/locales/zh-CN/models.json | 5 +- .../mobile/src/i18n/locales/zh-TW/models.json | 5 +- .../src/session/ModelOptionsSheetView.tsx | 11 ++ apps/mobile/src/session/draftModelMemory.ts | 18 ++ apps/mobile/src/session/modelPickerRows.ts | 3 +- .../src/session/providerModelSections.ts | 2 +- apps/mobile/src/session/sessionModelMirror.ts | 13 +- docs/dev-rules/model-catalog-maintenance.md | 15 +- docs/dev-rules/protocol-compatibility.md | 3 + .../model-metadata-precedence.md | 10 +- .../scripts/exportPublishedCatalog.ts | 57 ++++++ .../src/__tests__/modelCatalogPolicy.test.ts | 169 ++++++++++++++++++ .../src/__tests__/source-registry.test.ts | 10 +- packages/model-providers/src/catalog.ts | 6 +- packages/model-providers/src/index.ts | 3 + .../model-providers/src/modelAccessBean.ts | 8 +- .../src/modelAccessValidator.ts | 13 +- .../model-providers/src/modelCatalogPolicy.ts | 155 ++++++++++++++++ packages/model-providers/src/modelRegistry.ts | 2 +- packages/model-providers/src/sections.ts | 2 + packages/model-providers/src/source.ts | 5 +- packages/model-providers/src/types.ts | 9 +- .../model-providers/src/unifiedSelection.ts | 4 + packages/model-providers/src/user-provider.ts | 36 ++-- 57 files changed, 838 insertions(+), 108 deletions(-) create mode 100644 apps/desktop/src/main/maker-host/__tests__/model-memory-reset.test.ts create mode 100644 apps/desktop/src/main/maker-host/model-memory-reset.ts create mode 100644 packages/model-providers/scripts/exportPublishedCatalog.ts create mode 100644 packages/model-providers/src/__tests__/modelCatalogPolicy.test.ts create mode 100644 packages/model-providers/src/modelCatalogPolicy.ts diff --git a/apps/desktop/src/main/maker-host/__tests__/activeCatalogRevision.test.ts b/apps/desktop/src/main/maker-host/__tests__/activeCatalogRevision.test.ts index 6c85ffe0f65..138d6f5ea6a 100644 --- a/apps/desktop/src/main/maker-host/__tests__/activeCatalogRevision.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/activeCatalogRevision.test.ts @@ -6,6 +6,8 @@ import { commitModelPlaneFromCatalog, getActiveCatalog, getActiveCatalogRevision, + getLocalCatalogOverridesSnapshot, + setLocalCatalogOverrides, setActiveCatalog, setActiveCatalogChangedListener, setAnthropicDiscoveredModels, @@ -229,3 +231,25 @@ describe('active catalog revision', () => { ); }); }); + + +it('applies a new publication to defaults while preserving personal model overrides and Pi membership', () => { + const original = getLocalCatalogOverridesSnapshot(); + const catalog = structuredClone(BUNDLED_CATALOG); + catalog.version = 'catalog-123'; catalog.modelRegistry!.schemaVersion = 5; + const entry = catalog.modelRegistry!.models.find((model) => model.id === 'openai/gpt-5.6-sol')!; + entry.productDefaults = { contextWindow: 240000, effort: 'low', fast: false, preferredAgent: 'codex' }; + const piIds = catalog.providers.find((provider) => provider.id === 'openai')?.models.pi?.map((model) => model.id); + const read = () => getActiveCatalog().providers.find((provider) => provider.id === 'openai')!.models.codex!.find((model) => model.id === 'gpt-5.6-sol')!; + try { + setLocalCatalogOverrides({ ...original, patches: {}, additions: {} }); + setActiveCatalog(catalog); + expect(read()).toMatchObject({ contextWindow: 240000, defaultEffort: 'low', defaultFast: false, catalogDefaults: { revision: 'catalog-123' } }); + expect(read().contextWindowMax).toBeGreaterThan(240000); + setLocalCatalogOverrides({ ...original, patches: { 'openai:gpt-5.6-sol': { base: { contextWindow: 500000, defaultEffort: 'high' } } }, additions: {} }); + entry.productDefaults = { contextWindow: 220000, effort: 'medium' }; + catalog.version = 'catalog-124'; setActiveCatalog(catalog); + expect(read()).toMatchObject({ contextWindow: 500000, defaultEffort: 'high', catalogDefaults: { revision: 'catalog-124' } }); + expect(getActiveCatalog().providers.find((provider) => provider.id === 'openai')?.models.pi?.map((model) => model.id)).toEqual(piIds); + } finally { setLocalCatalogOverrides(original); setActiveCatalog(BUNDLED_CATALOG); } +}); diff --git a/apps/desktop/src/main/maker-host/__tests__/model-memory-reset.test.ts b/apps/desktop/src/main/maker-host/__tests__/model-memory-reset.test.ts new file mode 100644 index 00000000000..d57e0d8185c --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/model-memory-reset.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ModelMemoryResetRequests } from '../model-memory-reset.js'; + +describe('persisted model memory reset acknowledgement', () => { + afterEach(() => vi.useRealTimers()); + it('correlates independent requests and rejects an unconfirmed write', async () => { + vi.useFakeTimers(); + const requests = new ModelMemoryResetRequests(); + const ids: string[] = []; + const first = requests.request((id) => ids.push(id)); + const second = requests.request((id) => ids.push(id)); + const rejected = expect(second).rejects.toThrow('MODEL_MEMORY_RESET_NOT_CONFIRMED'); + requests.acknowledge('unrelated'); + requests.acknowledge(ids[0]); + await expect(first).resolves.toEqual({ resetApplied: true }); + await vi.advanceTimersByTimeAsync(5000); + await rejected; + requests.acknowledge(ids[1]); + }); +}); + + +it('rejects a stale acknowledgement after the account boundary changes', async () => { + const requests = new ModelMemoryResetRequests(); + let owner = 'a'; let requestId = ''; + const pending = requests.request((id) => { requestId = id; }, () => owner === 'a'); + owner = 'b'; + const failure = expect(pending).rejects.toThrow('OWNER_CHANGED'); + expect(requests.acknowledge(requestId)).toBe(false); + await failure; +}); diff --git a/apps/desktop/src/main/maker-host/__tests__/providerCatalogRealmReload.test.ts b/apps/desktop/src/main/maker-host/__tests__/providerCatalogRealmReload.test.ts index 9e24fd47a67..a5b1369a534 100644 --- a/apps/desktop/src/main/maker-host/__tests__/providerCatalogRealmReload.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/providerCatalogRealmReload.test.ts @@ -693,9 +693,9 @@ describe('provider catalog realm reload', () => { it('invalidates the old realm immediately and ignores a stale cross-realm response', async () => { const initial = ensureActiveCatalogLoaded(); + expect(h.loads[0]?.source).not.toHaveProperty('fallbackBaseUrl'); expect(h.loads[0]?.source).toMatchObject({ baseUrl: 'https://model.cn.example', - fallbackBaseUrl: 'https://legacy-build-cdn.example', }); h.loads[0]!.resolve(catalogNamed('catalog-cn-initial'), 'current', ['embedding']); await initial; diff --git a/apps/desktop/src/main/maker-host/active-catalog.ts b/apps/desktop/src/main/maker-host/active-catalog.ts index 775881c1b6d..11641798b53 100644 --- a/apps/desktop/src/main/maker-host/active-catalog.ts +++ b/apps/desktop/src/main/maker-host/active-catalog.ts @@ -4,6 +4,8 @@ import { applyLocalModelCatalogOverrides, } from './model-plane/localCatalogOverrides.js'; import { + applyModelProductDefaults, + resolveModelProductDefaults, resolveModelMetadata, catalogModelMetadata, applyModelMetadata, @@ -13,9 +15,8 @@ import { /** * active-catalog —— 进程级「当前生效目录」单例(纯状态 holder,零 Electron 依赖)。 * - * 设计(用户敲定):OSS 上的 `providers.json` 是运行时真源,启动时(splash 阶段)由 - * `ensureActiveCatalogLoaded`(见 createDesktopProviderService.ts)拉取一次、存进这里、 - * **无 TTL**;内置 `BUNDLED_CATALOG` 仅作「尚未加载完成 / 拉取失败」时的兜底。 + * 区域 Model Access 发布目录是公共元数据来源,启动与定时刷新安装到此 holder。 + * 内置目录和上次有效快照只用于离线兜底,个人覆盖始终最后应用。 * * **自定义供应商**:用户在本机配置的 user provider(见 custom-provider-store)经 * `buildUserProvider` 展开成标准 `Provider` 后由 `setCustomProviders` 注入,**追加在内置之后**。 @@ -354,7 +355,7 @@ function preserveNonGrok46DiscoveryEfforts( models: readonly CatalogModel[], discovered: readonly XaiDiscoveredModel[], ): CatalogModel[] { - if ((base ?? BUNDLED_CATALOG).modelRegistry?.schemaVersion === 4) return [...models]; + if ([4, 5].includes((base ?? BUNDLED_CATALOG).modelRegistry?.schemaVersion ?? 0)) return [...models]; const byId = new Map(discovered.map((entry) => [entry.id, entry])); return models.map((model) => { const entry = byId.get(model.id) ?? byId.get(`xai/${model.id}`); @@ -832,7 +833,7 @@ function assembleRoot( }); } const registry = (base ?? BUNDLED_CATALOG).modelRegistry; - if (registry?.schemaVersion === 4) { + if ([4, 5].includes(registry?.schemaVersion ?? 0)) { const live = new Map(models.map((model) => [model.id, model])); out = out.map((model) => { const upstream = live.get(model.id); @@ -869,7 +870,7 @@ function applyLayeredConsumer( ): CatalogModel { const overlaid = applyRegistryConsumerOverlay(model, providerId, agent, model.id, plan); const registry = (base ?? BUNDLED_CATALOG).modelRegistry; - return registry?.schemaVersion === 4 + return [4, 5].includes(registry?.schemaVersion ?? 0) ? applyModelMetadata( overlaid, resolveModelMetadata( @@ -1195,7 +1196,7 @@ function computeMerged(): Catalog { ...(model.contextWindowMax !== undefined ? { contextWindowMax: model.contextWindowMax } : {}), - ...((base ?? BUNDLED_CATALOG).modelRegistry?.schemaVersion !== 4 && + ...(![4, 5].includes((base ?? BUNDLED_CATALOG).modelRegistry?.schemaVersion ?? 0) && model.supportsFastMode === false ? { supportsFastMode: false } : {}), @@ -1380,7 +1381,7 @@ function computeMerged(): Catalog { ]); const registryDefault = registryEntry ? modelDefaultEffort(registryEntry) : undefined; const intent = - b.modelRegistry?.schemaVersion === 4 + [4, 5].includes(b.modelRegistry?.schemaVersion ?? 0) ? (ov.defaultEffort ?? gm.defaultEffort ?? defaultEffortForCapabilities(efforts)) : registryDefault !== undefined ? registryDefault @@ -1500,7 +1501,7 @@ function computeMerged(): Catalog { )?.entry : undefined; const intent = - b.modelRegistry?.schemaVersion !== 4 && entry ? modelDefaultEffort(entry) : undefined; + ![4, 5].includes(b.modelRegistry?.schemaVersion ?? 0) && entry ? modelDefaultEffort(entry) : undefined; const defaultEffort = intent !== undefined ? model.efforts.length === 0 @@ -1515,6 +1516,7 @@ function computeMerged(): Catalog { ['openai', 'xd'].includes(provider.id) && /^(?:(?:codex|openai|chatgpt)\/)?gpt-/.test(model.id) && model.contextWindow > 272_000 && + resolveModelProductDefaults(b.modelRegistry, provider.id, model.id, agent as AgentKind)?.contextWindow === undefined && !( (agent === 'codex' || agent === 'claude-code' || agent === 'pi') && hasLocalContextWindowOverride( @@ -1582,7 +1584,7 @@ function computeMerged(): Catalog { models?.map((model) => { let next = model; if ( - b.modelRegistry?.schemaVersion === 4 && + [4, 5].includes(b.modelRegistry?.schemaVersion ?? 0) && provider.source !== 'user' && (provider.id === 'xd' || agent === 'pi') ) { @@ -1603,6 +1605,7 @@ function computeMerged(): Catalog { ['openai', 'xd'].includes(provider.id) && /^(?:(?:codex|openai|chatgpt)\/)?gpt-/.test(next.id) && next.contextWindow > 272_000 && + resolveModelProductDefaults(b.modelRegistry, provider.id, next.id, agent as AgentKind)?.contextWindow === undefined && !( (agent === 'codex' || agent === 'claude-code' || agent === 'pi') && hasLocalContextWindowOverride( @@ -1619,6 +1622,9 @@ function computeMerged(): Catalog { contextWindow: 272_000, }; } + if (provider.source !== 'user') { + next = applyModelProductDefaults(next, resolveModelProductDefaults(b.modelRegistry, provider.id, model.id, agent as AgentKind), b.version); + } const identity = findModelRegistryRoute( b.modelRegistry, @@ -1773,7 +1779,7 @@ function installActiveCatalog( baseUnverifiedXdMediaKinds = nextUnverifiedXdMediaKinds; if (customConfigs) { custom = customConfigs.map((config) => - buildUserProvider(config, { modelRegistry: projectionRegistry, presets: catalog.presets }), + buildUserProvider(config, { modelRegistry: projectionRegistry, presets: catalog.presets, catalogRevision: catalog.version }), ); } markChanged(); @@ -1861,6 +1867,7 @@ export function commitModelPlaneFromCatalog( buildUserProvider(config, { modelRegistry: trustedCustomProviderRegistry, presets: (base ?? BUNDLED_CATALOG).presets, + catalogRevision: (base ?? BUNDLED_CATALOG).version, }), ); } @@ -1906,6 +1913,7 @@ export function setCustomProviderConfigs(configs: CustomProviderConfig[]): void buildUserProvider(config, { modelRegistry: trustedCustomProviderRegistry, presets: (base ?? BUNDLED_CATALOG).presets, + catalogRevision: (base ?? BUNDLED_CATALOG).version, }), ); markChanged(); diff --git a/apps/desktop/src/main/maker-host/createDesktopProviderService.ts b/apps/desktop/src/main/maker-host/createDesktopProviderService.ts index 1d266c329fc..badc3e7831c 100644 --- a/apps/desktop/src/main/maker-host/createDesktopProviderService.ts +++ b/apps/desktop/src/main/maker-host/createDesktopProviderService.ts @@ -5,10 +5,10 @@ import { filterLegacyGptContextProfiles } from './legacy-context-profiles.js'; * 两块职责: * 1. 目录加载器 `ensureActiveCatalogLoaded`:用 electron net.request 拉公共 Catalog、node fs 读 dev * 本地文件,把结果写进 active-catalog 单例(getActiveCatalog 同步读)。 - * - release / dev:都从区域化 Model Access 公共接口加载,失败时回退旧 OSS 目录。 + * - release / dev:都从区域化 Model Access 公共接口加载,失败时使用当前端点缓存及内置兜底。 * - dev 可由 XDT_MODELS_PATH 指向本地文件即时生效(本地文件优先于远端)。 * - env 兜底:XDT_MODELS_URL(完整覆盖 URL)/ XDT_DISABLE_MODELS_FETCH(强制不联网)。 - * **每进程拉一次、存内存、无 TTL**:启动总是先拉远端;失败时才读按端点隔离的 + * 启动加载并由 provider-model-auto-refresh 在前台活动时定期刷新;失败时读按端点隔离的 * last-known-good 快照,最后回退 bundled。 * 启动期(splash)由 bootstrap-electron 在构造 Maker 前 await 一次(见 registerMakerIpcsAfterSplash)。 * 2. `getDesktopProviderService`:把 active-catalog + 连接状态读取器注入 provider-service。 @@ -45,9 +45,8 @@ import { isMediaModelExecutable, } from '../model-access/mediaModels.js'; import { hasCustomProviderCredential } from './provider-connection-state.js'; -import { getBaseUrl } from '../manifestService.js'; import { throwIpcError } from '../utils/ipcValidate.js'; -import { getBuildClientEndpoint, getClientEndpoint } from '../clientEndpointsService.js'; +import { getClientEndpoint } from '../clientEndpointsService.js'; import { commitActiveCatalogSnapshot, getActiveCatalog, @@ -347,21 +346,16 @@ const io: CatalogIO = { }; /** - * 构建目录源配置。release 与 dev 统一使用区域化 Model Access 公共接口,旧 OSS 保留为迁移期回退。 - * - * 会话切到另一 auth realm 时,Model Access 公共接口随 active endpoint 改变并触发整份目录 - * 重载。旧 OSS 只属于安装包区域,因此仅同区加载允许使用;跨区主源失败时直接退化 bundled, - * 绝不把安装区域的 provider/routing 目录冒充成组织区域目录。 + * 运行期只访问当前区域 Model Access 发布接口;失败沿用本区域 LKG 或随包离线目录。 + * 显式开发覆盖仍可通过既有 XDT_MODELS_URL / XDT_MODELS_PATH 使用。 */ function buildSource(): CatalogSourceConfig { const explicitUrl = process.env.XDT_MODELS_URL; const baseUrl = getClientEndpoint('modelAccessApiBaseUrl'); - const usesBuildRealm = baseUrl === getBuildClientEndpoint('modelAccessApiBaseUrl'); return { url: explicitUrl, localPath: process.env.XDT_MODELS_PATH, baseUrl, - fallbackBaseUrl: !usesBuildRealm ? undefined : getBaseUrl(), remoteBudgetMs: DEFAULT_REMOTE_CATALOG_BUDGET_MS, disableFetch: process.env.XDT_DISABLE_MODELS_FETCH === '1', }; diff --git a/apps/desktop/src/main/maker-host/model-memory-reset.ts b/apps/desktop/src/main/maker-host/model-memory-reset.ts new file mode 100644 index 00000000000..97650e63d6b --- /dev/null +++ b/apps/desktop/src/main/maker-host/model-memory-reset.ts @@ -0,0 +1,37 @@ +import { randomUUID } from 'node:crypto'; + +/** Correlates an explicit reset with the trusted renderer's persisted-memory acknowledgement. */ +export class ModelMemoryResetRequests { + private readonly pending = new Map< + string, + { resolve: () => void; reject: (error: Error) => void; isCurrent: () => boolean; timer: ReturnType } + >(); + request(send: (requestId: string) => void, isCurrent = () => true): Promise<{ resetApplied: true }> { + if (this.pending.size >= 100) return Promise.reject(new Error('MODEL_MEMORY_RESET_BUSY')); + const id = randomUUID(); + return new Promise<{ resetApplied: true }>((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error('MODEL_MEMORY_RESET_NOT_CONFIRMED')); + }, 5000); + this.pending.set(id, { resolve: () => resolve({ resetApplied: true }), reject, timer, isCurrent }); + try { + send(id); + } catch (error) { + clearTimeout(timer); + this.pending.delete(id); + reject(error); + } + }); + } + acknowledge(id: unknown): boolean { + if (typeof id !== 'string') return false; + const request = this.pending.get(id); + if (!request) return false; + clearTimeout(request.timer); + this.pending.delete(id); + if (!request.isCurrent()) { request.reject(new Error('MODEL_MEMORY_RESET_OWNER_CHANGED')); return false; } + request.resolve(); + return true; + } +} diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index e69f2b1d463..12b5a8ab1b3 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -1,3 +1,4 @@ +import { ModelMemoryResetRequests } from '../maker-host/model-memory-reset.js'; import { registerPluginListHandler } from './pluginListHandler.js'; import { initializeBotAuthorizationHost } from './botAuthorizationHost.js'; import { resolveBotAuthorizationDelivery, buildBotAuthorizationContinuation, commitBotAuthorizationInput, type BotAuthorizationInputGuard, getBotAuthorizationService } from './botAuthorizationService.js'; @@ -3173,6 +3174,7 @@ let pendingAgentSwitchApplyHolder: let cancelPendingAgentSwitchHolder: ((sessionId: string) => void) | null = null; let gitSnapshotCoordinator: GitSnapshotCoordinator | null = null; const sessionTurnActivityTracker = new SessionTurnActivityTracker(); +const modelMemoryResetRequests = new ModelMemoryResetRequests(); const reviewRunOwner: ReviewRunOwner = { instanceId: randomUUID(), processId: process.pid }; const sessionTurnLeaseTracker = new SessionTurnLeaseTracker({ getDbClient, @@ -4805,9 +4807,11 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) // device-link:被控端 providerModelMemory(草稿列表行的真实读源)全量镜像给 main。旧的 // newMakerDraft.effortByModel 已不再写非选中模型,故必须把这一层也同步出去,控制端才能完整镜像 // 被控端草稿模型列表。镜像更新后同样广播 NEW_MAKER_DRAFT_CHANGED(payload 含 providerModelMemory)。 - ipcMain.on(MAKER_SEND.SYNC_PROVIDER_MODEL_MEMORY, (_e, payload: unknown) => { + ipcMain.on(MAKER_SEND.SYNC_PROVIDER_MODEL_MEMORY, (event, payload: unknown, resetRequestId: unknown) => { + assertTrustedAppRendererEvent(event); if (!payload || typeof payload !== 'object') return; // 结构宽松校验:顶层是 object,值再交给消费方按字段读(脏数据不至于崩,缺字段回落默认)。 + if (resetRequestId !== undefined && !modelMemoryResetRequests.acknowledge(resetRequestId)) return; setProviderModelMemoryCache(payload as ProviderModelMemorySnapshot); broadcastNewMakerDraftChanged(); }); @@ -5162,6 +5166,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) thinking?: unknown; active?: unknown; markModelChoice?: unknown; + reset?: unknown; }; if (p.agent !== 'claude-code' && p.agent !== 'codex' && p.agent !== 'pi') { throwIpcError('INVALID_PARAMS', 'agent must be claude-code|codex|pi'); @@ -5187,6 +5192,16 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) if (p.markModelChoice !== undefined && typeof p.markModelChoice !== 'boolean') { throwIpcError('INVALID_PARAMS', 'markModelChoice must be boolean'); } + if (p.reset !== undefined) { + if (!Array.isArray(p.reset) || !p.reset.length || p.reset.length > 2 || p.reset.some((field) => field !== 'effort' && field !== 'fast') + || p.active === true || p.effort !== undefined || p.fast !== undefined || p.thinking !== undefined) { + throwIpcError('INVALID_PARAMS', 'invalid model memory reset'); + } + const resetOwnerScope = activeOwnerScopeKey(); + return modelMemoryResetRequests.request((resetRequestId) => { + broadcastToAllWindows(MAKER_PUSH.DRAFT_PREF_APPLY, { agent: p.agent, providerId: p.providerId, modelId: p.modelId, active: false, reset: p.reset, resetRequestId, resetDataOwnerId: getActiveAppSession().dataOwnerId }); + }, () => activeOwnerScopeKey() === resetOwnerScope && !isAppSessionBoundaryPending()); + } broadcastToAllWindows(MAKER_PUSH.DRAFT_PREF_APPLY, { agent: p.agent, providerId: p.providerId ?? '', @@ -5360,7 +5375,10 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) listProviderIds: () => getDesktopSelectableCatalog().providers.map((provider) => provider.id), setProviderOrder: (providerIds) => setProviderOrder(providerIds), getProviderOrder: () => readProviderOrder(), - listPresets: () => getActiveCatalog().presets ?? [], + listPresets: () => { + const catalog = getActiveCatalog(); + return (catalog.presets ?? []).filter((preset) => !catalog.modelRegistry?.disabledPresetIds?.includes(preset.id)); + }, testConnection: (input) => testProviderConnection(input), fetchModels: (spec) => fetchProviderModels(spec), // 重新发现会用订阅凭证发起真实上游请求,限主页面 sender(子 frame / WebView 拒绝)。 diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 98b26287a69..304014d1157 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2458,7 +2458,8 @@ contextBridge.exposeInMainWorld('electronAPI', { string, { effortByModel: Record; fastByModel: Record } >, - ): void => ipcRenderer.send('maker:sync-provider-model-memory', snapshot), + resetRequestId?: string, + ): void => ipcRenderer.send('maker:sync-provider-model-memory', snapshot, resetRequestId), /** * 被控端 renderer → 自身 main:会话「非选中模型」effort/fast 在本端变化时镜像给 main, diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index e413a78b705..dcb0884e94b 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -56,6 +56,10 @@ import { patchVendorPrefsPreservingModelChoice, } from '@/state/newMakerDraft'; import { + hasPendingProviderModelMemoryWrites, + isProviderModelMemoryOwner, + clearProviderModelEffort, + clearProviderModelFast, snapshotForSeed, setProviderModelChoice, setProviderModelEffort, @@ -245,7 +249,14 @@ export function App() { // 通过 providerModelMemory 同步。写入触发上面的镜像 effect → NEW_MAKER_DRAFT_CHANGED 回流控制端。 useEffect(() => { const offDraft = window.electronAPI.onMakerDraftPrefApply( - ({ agent, providerId, modelId, active, effort, fast, thinking, markModelChoice }) => { + ({ agent, providerId, modelId, active, effort, fast, thinking, markModelChoice, reset, resetRequestId, resetDataOwnerId }) => { + if (reset && resetRequestId) { + if (!isProviderModelMemoryOwner(resetDataOwnerId)) return; + if (reset.includes('effort')) clearProviderModelEffort(agent, providerId, modelId); + if (reset.includes('fast')) clearProviderModelFast(agent, providerId, modelId); + if (!hasPendingProviderModelMemoryWrites()) window.electronAPI.syncProviderModelMemory(snapshotForSeed(), resetRequestId); + return; + } const vendor = agentKindToVendor(agent); if (active) { const patch = diff --git a/apps/desktop/src/renderer/__tests__/deviceLinkModelMirror.test.ts b/apps/desktop/src/renderer/__tests__/deviceLinkModelMirror.test.ts index 43e75ba5896..6a3cd629d1d 100644 --- a/apps/desktop/src/renderer/__tests__/deviceLinkModelMirror.test.ts +++ b/apps/desktop/src/renderer/__tests__/deviceLinkModelMirror.test.ts @@ -139,3 +139,22 @@ describe('makeMirrorAccessors', () => { expect(acc.getEffort('claude-code', 'anthropic', 'opus')).toBe('medium'); }); }); + + +describe('remote reset confirmation', () => { + it('keeps an override for an old or offline host, then deletes only the acknowledged field', async () => { + replaceScope(DRAFT, { 'codex:openai': { effortByModel: { model: 'high' }, fastByModel: { model: true } } }); + const unsupported = makeMirrorAccessors(DRAFT, () => undefined); + await expect(unsupported.clearEffort!('codex', 'openai', 'model')).rejects.toThrow('UNSUPPORTED'); + expect(unsupported.getEffort('codex', 'openai', 'model')).toBe('high'); + const offline = makeMirrorAccessors(DRAFT, async () => { throw new Error('offline'); }); + await expect(offline.clearFast!('codex', 'openai', 'model')).rejects.toThrow('offline'); + expect(offline.getFast('codex', 'openai', 'model')).toBe(true); + const confirmed = makeMirrorAccessors(DRAFT, async () => ({ resetApplied: true })); + await confirmed.clearEffort!('codex', 'openai', 'model'); + expect(confirmed.getEffort('codex', 'openai', 'model')).toBeUndefined(); + expect(confirmed.getFast('codex', 'openai', 'model')).toBe(true); + await confirmed.clearFast!('codex', 'openai', 'model'); + expect(confirmed.getFast('codex', 'openai', 'model')).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx index e1d14dadedb..902049d27db 100644 --- a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx @@ -6065,9 +6065,11 @@ export function ChatInput({ // providerId 只用于来源 capability 与旧 v2 兼容回退;新预设按 (agent, model) 跨来源共享。 // 无 providerId / device-link(modelMemory 为 undefined)→ false,且不掺控制端本机记忆。 if (!currentModelAgentKind || !providerId || !modelMemory) return false; - return modelMemory.getFast(currentModelAgentKind, providerId, targetModelId) ?? false; + return modelMemory.getFast(currentModelAgentKind, providerId, targetModelId) + ?? (deviceLinkDeviceId ? remoteProviders.providers : localProviders.providers).find((provider) => provider.id === providerId)?.models[currentModelAgentKind]?.find((model) => model.id === targetModelId)?.defaultFast + ?? false; }, - [currentModelAgentKind, modelMemory, modelFastSupported], + [currentModelAgentKind, modelMemory, modelFastSupported, deviceLinkDeviceId, remoteProviders.providers, localProviders.providers], ); const syncSessionDraftModelPrefs = useCallback( @@ -6538,7 +6540,7 @@ export function ChatInput({ : fastCapable && !!providerId && !!modelMemory && - (modelMemory.getFast(targetAgentKind, providerId, newModelId) ?? false); + (modelMemory.getFast(targetAgentKind, providerId, newModelId) ?? (deviceLinkDeviceId ? remoteProviders.providers : localProviders.providers).find((provider) => provider.id === providerId)?.models[targetAgentKind]?.find((model) => model.id === newModelId)?.defaultFast ?? false); // 会话级操作按来源路由:device-link 远程会话隧道到被控端(意图注册表与引擎 // 交接都在那边),本机会话零变化直连本机 maker。 diff --git a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx index 27c6510d353..83c728020ea 100644 --- a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx @@ -173,9 +173,9 @@ export interface ModelMemoryAccessors { * wire protocol 变更,不在本次范围)。没注入时 `resetToRecommended` 退回既有的快照写法, * 行为与改动前一致。 */ - clearEffort?: (agent: AgentKind, providerId: string, modelId: string) => void; + clearEffort?: (agent: AgentKind, providerId: string, modelId: string) => void | Promise; /** 同 `clearEffort`,针对 Fast(缺省即关,所以删除与写 false 显示等价,但不钉住默认)。 */ - clearFast?: (agent: AgentKind, providerId: string, modelId: string) => void; + clearFast?: (agent: AgentKind, providerId: string, modelId: string) => void | Promise; } // 配置面板锚在主菜单内缩 8px 的模型行上;补偿这段内缩,让两块面板贴边但不重叠。 @@ -439,6 +439,7 @@ function formatContextWindow(tokens: number): string { // 单栏列表里每行 / Edit 配置列消费的最小模型形状(SectionModel 与 renderer ModelDescriptor 都满足)。 interface RowModel { + defaultFast?: boolean; id: string; displayName: string; description?: string; @@ -1736,7 +1737,7 @@ function ModelSelectorContentView({ if (!fastEditable(providerId, m)) return false; if (isSelectedRow(providerId, m.id)) return fastMode; if (!currentAgentKind || !providerId) return false; - return modelMemory?.getFast(currentAgentKind, providerId, m.id) ?? false; + return modelMemory?.getFast(currentAgentKind, providerId, m.id) ?? m.defaultFast ?? false; }; // 某 (供应商, 模型) 行当前要展示的 effort(选中 → 调用方值;否则全局模型预设 → 模型默认)。 diff --git a/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts b/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts index 9c8d04fd637..6bba2dc453c 100644 --- a/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts +++ b/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts @@ -200,7 +200,7 @@ export function resolveUnifiedRowConfig(args: ResolveRowConfigArgs): UnifiedRowC const effort = pickEffort(capability, memoryEffort?.(agent)); const fastCapable = capability?.supportsFastMode === true && (agentFastModeCapable?.(agent) ?? true); - const fast = fastCapable ? (memoryFast?.(agent) ?? false) : false; + const fast = fastCapable ? (memoryFast?.(agent) ?? capability?.defaultFast ?? false) : false; const customized = // 「已自定义」是相对**该行此刻的缺省**说的:会话内 pinned 生效时,落在当前引擎上 // 是缺省而不是自定义(否则会话里几乎每一行都被标成已自定义,提亮就失去信息量)。 @@ -208,7 +208,7 @@ export function resolveUnifiedRowConfig(args: ResolveRowConfigArgs): UnifiedRowC // 用户留过的引擎选择仍应提亮。 (overrideUsable && engineOverride !== baseline) || (effort !== null && capability?.defaultEffort != null && effort !== capability.defaultEffort) || - fast; + fast !== (fastCapable && capability?.defaultFast === true); return { engine, agent, diff --git a/apps/desktop/src/renderer/components/new-chat/useUnifiedRowActions.ts b/apps/desktop/src/renderer/components/new-chat/useUnifiedRowActions.ts index 33e47a93103..b825a7ffa9d 100644 --- a/apps/desktop/src/renderer/components/new-chat/useUnifiedRowActions.ts +++ b/apps/desktop/src/renderer/components/new-chat/useUnifiedRowActions.ts @@ -340,7 +340,7 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified * 回滚本身也可能失败(隧道断了 / 持久化失败):此时两侧都脏,但**存储仍未清**,面板与 * 记忆里的还是原配置,用户重试整段即可 —— 所以照旧返回 false,绝不把它当成功收尾。 */ - const applyDefaultsLive = async (effort: Effort | null): Promise => { + const applyDefaultsLive = async (effort: Effort | null, fast = false): Promise => { // 快照必须在第一笔写出去之前取:它就是 onEffortChangeLive 写的那个格子的原值。 const previousEffort = liveEffort ?? null; let effortWritten = false; @@ -350,7 +350,7 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified effortWritten = previousEffort !== null && previousEffort !== effort; } if (onFastModeChangeLive) { - if (!(await runLive(() => onFastModeChangeLive(false)))) { + if (!(await runLive(() => onFastModeChangeLive(fast)))) { if (effortWritten && previousEffort && onEffortChangeLive) { await runLive(() => onEffortChangeLive(previousEffort)); } @@ -391,7 +391,7 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified * 的收藏就走不到「先回落默认配置」那条路。 */ favoriteUid: string | null; - onApplied: () => void; + onApplied: () => void | Promise; }): Promise => { if (!sessionEngineFilter) return Promise.resolve(); return runLive(() => @@ -408,7 +408,7 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified // 只有明确的 false 表示「没切」(见 UnifiedModelPanelProps.onCrossEngineSelect); // 返回 void 的调用方视为已切。 if (applied === false) return; - args.onApplied(); + return runLive(args.onApplied).then(() => undefined); }, // 事务抛错(切换失败)同样按「没应用」处理。 () => {}, @@ -419,17 +419,18 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified * 草稿应用:换引擎无损,按**既有选中链路**把整份默认 / 推荐配置写回草稿 * (与 applyEngine 的草稿分支同形)。`favoriteUid: null` 是这条链路的要点之一 —— * 草稿层的收藏锚点由它清掉,否则删完收藏草稿还指着一个不存在的 uid。 - * `fast` 恒 false:两个入口交出来的都是默认 / 推荐态,那里没有 Fast。 + * 未显式传入 `fast` 时沿用关闭;默认 / 推荐入口传入当前目录的默认值。 */ const applyDefaultsToDraft = (args: { anchor: UnifiedAnchor; engine: UnifiedEngine; wireModelId: string; effort: Effort | null; + fast?: boolean; }): ActionResult => { return onSelect(args.anchor.providerId, args.wireModelId, args.effort ?? '', { engine: args.engine, - fast: false, + fast: args.fast ?? false, favoriteUid: null, rowModelId: args.anchor.modelId, resetToRecommended: true, @@ -720,6 +721,7 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified const resetToRecommended: UnifiedRowActions['resetToRecommended'] = (anchor, entry, config) => { if (interactionDisabled || anchor.kind === 'fav') return; const recommendedAgent = entry.recommended; + const defaultFast = entry.capabilities[recommendedAgent]?.defaultFast === true && entry.capabilities[recommendedAgent]?.supportsFastMode === true; const recommendedEngine = engineOfAgentKind(recommendedAgent); // 推荐档一律取 M1 已解析的那一份(`UnifiedAgentCapability.defaultEffort`,缺省回落 // 已经在那边应用过),不在这里另推一遍 —— 两处各推必然漂移。 @@ -748,8 +750,7 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified ); /** 「跟随推荐」的持久化部分:删 override + 删掉当前与推荐引擎两格的深度 / Fast 记忆。 */ - const resetStoredConfig = (): void => { - clearModelEngineOverride(anchor.providerId, anchor.modelId); + const resetStoredConfig = async (): Promise => { // ★ 删,不是写快照(2026-08-17 review H3)。记忆表是 override 表:表里没有该键 // ⇒ 跟随当前版本的目录默认。此前这里把**这一版**的 defaultEffort 快照写回记忆槽, // 于是服务端之后改了推荐档,点过「恢复推荐」的用户被钉死在旧值上 —— 与 @@ -758,7 +759,7 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified // 既有的快照写法,行为与改动前一致。 for (const slot of memorySlots) { if (modelMemory?.clearEffort) { - modelMemory.clearEffort(slot.agent, anchor.providerId, slot.wireModelId); + await modelMemory.clearEffort(slot.agent, anchor.providerId, slot.wireModelId); } else if (slot.defaultEffort) { modelMemory?.setEffort( slot.agent, @@ -774,17 +775,17 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified // 记忆表缺省即「关」,所以删除与写 false 的显示等价,但删除不会把「关」固化成用户配置。 for (const slot of memorySlots) { if (modelMemory?.clearFast) { - modelMemory.clearFast(slot.agent, anchor.providerId, slot.wireModelId); + await modelMemory.clearFast(slot.agent, anchor.providerId, slot.wireModelId); } else { modelMemory?.setFast(slot.agent, anchor.providerId, slot.wireModelId, false); } } + clearModelEngineOverride(anchor.providerId, anchor.modelId); }; // 非 live 行:改的只是「下次选它用什么」,清记忆就够了。 if (!isLiveRow(entry, config)) { - resetStoredConfig(); - return; + return runLive(resetStoredConfig).then(() => undefined); } // 本地/被控端草稿没有会话运行态;即使推荐引擎没变,也必须走草稿整行直通。 @@ -797,9 +798,10 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified engine: recommendedEngine, wireModelId: recommendedWireId, effort: defaultEffort, + fast: defaultFast, }), ).then((applied) => { - if (applied) resetStoredConfig(); + if (applied) return runLive(resetStoredConfig).then(() => undefined); }); } @@ -812,13 +814,13 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified // 顺序与跨引擎分支一致(2026-08-17 review 第三轮 G2):**两笔实时写入都成功了才**清存储。 // 反过来先清后写,一旦远程 setEffort / setFastMode 失败,override 与记忆已经没了、 // 任务还在旧配置上跑 —— 面板显示的推荐态与事实分家,且没有可回滚的原值。 - return applyDefaultsLive(defaultEffort).then((applied) => { + return applyDefaultsLive(defaultEffort, defaultFast).then(async (applied) => { if (!applied) return; - resetStoredConfig(); + if (!(await runLive(resetStoredConfig))) return; return clearFavoriteAnchorForLiveRow(anchor, { ...config, effort: defaultEffort, - fast: false, + fast: defaultFast, }); }); } @@ -837,7 +839,7 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified // 推荐态没有 Fast:**显式关**(2026-08-17 review)。留给事务重解析会读回目标引擎 // 记忆里残留的 Fast —— 恢复完的任务还插队加速,与同引擎分支 applyDefaultsLive // 的无条件关不一致。 - fast: false, + fast: defaultFast, // 恢复推荐 = 回到「这一行的默认配置」,不再跟着任何收藏副本跑 → 清锚点 // (与草稿分支 applyDefaultsToDraft 里的 `favoriteUid: null` 同一语义)。 favoriteUid: null, @@ -899,6 +901,7 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified engine: fallback.engine, wireModelId, effort: fallback.effort, + fast: fallback.fast, }), ).then((applied) => { if (applied) commit(); @@ -923,7 +926,7 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified } // 会话 + 默认引擎 == 正在跑的引擎,且没有待发送意图:无损,两个 live 回调把深度 / Fast 复位。 // 与跨引擎分支同一条顺序:**live 真写成了才**删记录。 - return applyDefaultsLive(fallback.effort).then((applied) => { + return applyDefaultsLive(fallback.effort, fallback.fast).then((applied) => { if (applied) commit(); }); }; diff --git a/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx b/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx index e98b926aaa2..ffa6480d9ee 100644 --- a/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx +++ b/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx @@ -436,6 +436,12 @@ export function ModelAdvancedDrawer({ key={`${provider.id}:${primaryModel.id}`} className="min-h-0 overflow-y-auto px-5 pb-5 pt-3" > + {primaryModel.catalogDefaults && ( +

+ {t('settings.providers.models.advanced.catalogRevision', { revision: primaryModel.catalogDefaults.revision ?? '—' })} + {' · '}{t(ctx.limit !== null ? 'settings.providers.models.advanced.personalWindow' : 'settings.providers.models.advanced.followCatalog')} +

+ )}
{conversational && ( diff --git a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx index bc3e841fbac..a20c30b8880 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx @@ -1149,13 +1149,14 @@ export function CCAgentSessionView({ if (!remoteDeviceId || !remoteModelMemoryScopeKey) return undefined; const deviceId = remoteDeviceId; return makeMirrorAccessors(remoteModelMemoryScopeKey, (agent, providerId, model, patch) => { - window.electronAPI.deviceLink + return window.electronAPI.deviceLink .invoke(deviceId, 'maker:apply-new-maker-draft-pref', [ { agent, providerId, modelId: model, active: false, + ...(patch.reset ? { reset: patch.reset } : {}), ...(patch.markModelChoice !== undefined ? { markModelChoice: patch.markModelChoice } : {}), @@ -1164,7 +1165,8 @@ export function CCAgentSessionView({ ...(patch.thinking !== undefined ? { thinking: patch.thinking } : {}), }, ]) - .catch(() => { + .catch((error) => { + if (patch.reset) throw error; // 旧版被控端 / 离线时仍保留控制端乐观镜像,不回退写本机预设。 }); }); diff --git a/apps/desktop/src/renderer/features/cc-agent/CreateWorkerPopover.tsx b/apps/desktop/src/renderer/features/cc-agent/CreateWorkerPopover.tsx index 2f9ca4ab44b..f34900abdd9 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CreateWorkerPopover.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CreateWorkerPopover.tsx @@ -467,7 +467,7 @@ export function CreateWorkerPopover({ const rememberedFast = !deviceId && providerId ? getProviderModelFast(agent, providerId, modelId) : undefined; - setFast((reconciledFast ?? rememberedFast) === true); + setFast((reconciledFast ?? rememberedFast ?? sourceEntry?.defaultFast) === true); } }, [ diff --git a/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx b/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx index df37e2954c1..9a27d03f2d8 100644 --- a/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx @@ -1435,9 +1435,9 @@ export function NewMakerDraftRoute() { const v = getProviderModelFast(capabilityAgentKind, effectiveSourceId, modelId); if (v !== undefined) return v; } - return getFastModeForModel(modelId); + return providers.find((provider) => provider.id === effectiveSourceId)?.models[capabilityAgentKind]?.find((model) => model.id === modelId)?.defaultFast ?? getFastModeForModel(modelId); }, - [effectiveSourceId, capabilityAgentKind], + [effectiveSourceId, capabilityAgentKind, providers], ); // ── device-link 远程草稿:全量镜像被控端「当前 New Maker 草稿」 ────────────────── @@ -1867,13 +1867,14 @@ export function NewMakerDraftRoute() { if (!mirrorScopeKey || !effectiveDeviceLinkDeviceId) return undefined; const deviceId = effectiveDeviceLinkDeviceId; return makeMirrorAccessors(mirrorScopeKey, (agent, providerId, model, patch) => { - window.electronAPI.deviceLink + return window.electronAPI.deviceLink .invoke(deviceId, 'maker:apply-new-maker-draft-pref', [ { agent, providerId, modelId: model, active: false, + ...(patch.reset ? { reset: patch.reset } : {}), ...(patch.markModelChoice !== undefined ? { markModelChoice: patch.markModelChoice } : {}), @@ -1882,7 +1883,8 @@ export function NewMakerDraftRoute() { ...(patch.thinking !== undefined ? { thinking: patch.thinking } : {}), }, ]) - .catch(() => { + .catch((error) => { + if (patch.reset) throw error; // CHANNEL_NOT_ALLOWED(旧版被控端)/ 离线 → 吞掉,保留控制端乐观镜像(优雅降级)。 }); }); diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index dd9d321c07a..c55961f6b6e 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -1935,7 +1935,10 @@ }, "codexContextUnknown": "CLI configuration unavailable", "codexContextValues": "Total {{total}} · Usable {{usable}} · Auto-compact {{compact}} tokens", - "contextLimitGptHint": "GPT defaults to at most 272K; you can set it to 1M here. Codex applies the window and automatic compaction threshold together, retaining 5% headroom. Actual capacity depends on the model service." + "contextLimitGptHint": "GPT defaults to at most 272K; you can set it to 1M here. Codex applies the window and automatic compaction threshold together, retaining 5% headroom. Actual capacity depends on the model service.", + "catalogRevision": "Published configuration: {{revision}}", + "personalWindow": "Using your context limit", + "followCatalog": "Following published defaults" }, "kindFilter": { "all": "All", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 55524909748..50d1d8b2bfa 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -1934,7 +1934,10 @@ }, "codexContextUnknown": "CLI 設定を取得できません", "codexContextValues": "総容量 {{total}} · 使用可能 {{usable}} · 自動圧縮 {{compact}} tokens", - "contextLimitGptHint": "GPT は既定で最大 272K を使用します。ここで 1M に変更できます。Codex はウィンドウと自動圧縮のしきい値を同時に適用し、5% の余裕を確保します。実際の容量はモデルサービスに依存します。" + "contextLimitGptHint": "GPT は既定で最大 272K を使用します。ここで 1M に変更できます。Codex はウィンドウと自動圧縮のしきい値を同時に適用し、5% の余裕を確保します。実際の容量はモデルサービスに依存します。", + "catalogRevision": "公開済み設定:{{revision}}", + "personalWindow": "設定したコンテキスト上限を使用中", + "followCatalog": "公開済みの既定値を使用中" }, "kindFilter": { "all": "すべて", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 2552f52dcd0..b006d79aaff 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -1934,7 +1934,10 @@ }, "codexContextUnknown": "CLI 설정을 읽을 수 없음", "codexContextValues": "전체 {{total}} · 사용 가능 {{usable}} · 자동 압축 {{compact}} tokens", - "contextLimitGptHint": "GPT는 기본적으로 최대 272K를 사용하며 여기서 1M으로 변경할 수 있습니다. Codex는 창과 자동 압축 임계값을 함께 적용하고 5%의 여유를 유지합니다. 실제 용량은 모델 서비스에 따라 다릅니다." + "contextLimitGptHint": "GPT는 기본적으로 최대 272K를 사용하며 여기서 1M으로 변경할 수 있습니다. Codex는 창과 자동 압축 임계값을 함께 적용하고 5%의 여유를 유지합니다. 실제 용량은 모델 서비스에 따라 다릅니다.", + "catalogRevision": "게시된 설정: {{revision}}", + "personalWindow": "사용자가 설정한 컨텍스트 제한 사용 중", + "followCatalog": "게시된 기본값 사용 중" }, "kindFilter": { "all": "전체", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index 1872981367b..ba760de0a2d 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -1934,7 +1934,10 @@ }, "codexContextUnknown": "未读取到 CLI 配置", "codexContextValues": "总窗口 {{total}} · 可用 {{usable}} · 自动压缩 {{compact}} tokens", - "contextLimitGptHint": "GPT 默认最多使用 272K;可在此自定义到 1M。Codex 会同步应用窗口与自动压缩阈值,保留 5% 余量;实际容量受模型服务限制。" + "contextLimitGptHint": "GPT 默认最多使用 272K;可在此自定义到 1M。Codex 会同步应用窗口与自动压缩阈值,保留 5% 余量;实际容量受模型服务限制。", + "catalogRevision": "发布配置:{{revision}}", + "personalWindow": "正在使用你的上下文限制", + "followCatalog": "正在跟随发布默认值" }, "kindFilter": { "all": "全部", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index 5b4c125fe32..e5079802acb 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -1934,7 +1934,10 @@ }, "codexContextUnknown": "未讀取到 CLI 設定", "codexContextValues": "總視窗 {{total}} · 可用 {{usable}} · 自動壓縮 {{compact}} tokens", - "contextLimitGptHint": "GPT 預設最多使用 272K;可在此自訂為 1M。Codex 會同步套用視窗與自動壓縮門檻,保留 5% 餘量;實際容量受模型服務限制。" + "contextLimitGptHint": "GPT 預設最多使用 272K;可在此自訂為 1M。Codex 會同步套用視窗與自動壓縮門檻,保留 5% 餘量;實際容量受模型服務限制。", + "catalogRevision": "發布設定:{{revision}}", + "personalWindow": "正在使用你的上下文限制", + "followCatalog": "正在跟隨發布預設值" }, "kindFilter": { "all": "全部", diff --git a/apps/desktop/src/renderer/state/deviceLinkModelMirror.ts b/apps/desktop/src/renderer/state/deviceLinkModelMirror.ts index 0165b64005a..52a057ba127 100644 --- a/apps/desktop/src/renderer/state/deviceLinkModelMirror.ts +++ b/apps/desktop/src/renderer/state/deviceLinkModelMirror.ts @@ -201,10 +201,21 @@ export function makeMirrorAccessors( agent: AgentKind, providerId: string, model: string, - patch: { effort?: Effort; fast?: boolean; thinking?: boolean; markModelChoice?: boolean }, - ) => void, + patch: { effort?: Effort; fast?: boolean; thinking?: boolean; markModelChoice?: boolean; reset?: ('effort' | 'fast')[] }, + ) => unknown | Promise, ): ModelMemoryAccessors { + const clear = async (field: 'effort' | 'fast', agent: AgentKind, providerId: string, model: string) => { + const response = await onWrite(agent, providerId, model, { reset: [field] }); + if (!response || typeof response !== 'object' || (response as { resetApplied?: unknown }).resetApplied !== true) { + throw new Error('MODEL_MEMORY_RESET_UNSUPPORTED'); + } + const slot = getSlot(scopeKey, agent, providerId, false); + if (slot) delete (field === 'effort' ? slot.effortByModel : slot.fastByModel)[model]; + scopeSerial.delete(scopeKey); emit(); + }; return { + clearEffort: (agent, providerId, model) => clear('effort', agent, providerId, model), + clearFast: (agent, providerId, model) => clear('fast', agent, providerId, model), getEffort: (agent, providerId, model) => getMirrorEffort(scopeKey, agent, providerId, model), getFast: (agent, providerId, model) => getMirrorFast(scopeKey, agent, providerId, model), getThinking: (agent, providerId, model) => getMirrorThinking(scopeKey, agent, providerId, model), diff --git a/apps/desktop/src/renderer/state/providerModelMemory.ts b/apps/desktop/src/renderer/state/providerModelMemory.ts index 131df8f1bf8..8e421995e8d 100644 --- a/apps/desktop/src/renderer/state/providerModelMemory.ts +++ b/apps/desktop/src/renderer/state/providerModelMemory.ts @@ -816,3 +816,13 @@ export function __resetForTest(): void { export const __STORAGE_KEY = STORAGE_KEY; export const __LEGACY_STORAGE_KEY_V1 = LEGACY_STORAGE_KEY_V1; + +/** A reset acknowledgement must wait until localStorage has accepted every queued operation. */ +export function hasPendingProviderModelMemoryWrites(): boolean { + return pendingOpsByStorageKey.has(storageKey()); +} + +/** Reset commands must refer to the same account as this renderer's persisted memory. */ +export function isProviderModelMemoryOwner(ownerId: string | null | undefined): boolean { + return ownerId !== undefined && ownerId === activeDataOwnerId; +} diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index 7333009fead..35cdaf324de 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -2425,6 +2425,7 @@ interface ElectronAPI { string, { effortByModel: Record; fastByModel: Record } >, + resetRequestId?: string, ) => void; /** 被控端 renderer → 自身 main:会话「非选中模型」effort/fast 变化镜像(转发给控制端)。 */ @@ -2448,6 +2449,9 @@ interface ElectronAPI { effort?: string; fast?: boolean; thinking?: boolean; + reset?: ('effort' | 'fast')[]; + resetRequestId?: string; + resetDataOwnerId?: string | null; }) => void, ) => () => void; diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index a97f55b6ed5..f29dbdf7aac 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -2434,6 +2434,7 @@ export default function SessionScreen() { ]); const sessionMirrorAccessors = useMemo( () => makeSessionMirrorAccessors(sessionId, (agent, providerId, model, patch) => { + if (patch.reset) return maker.resetModelMemory({ agent, providerId, modelId: model }); void maker.setSessionModelPref({ sessionId, agent, providerId, model, ...patch }).catch(() => undefined); void maker.applyNewMakerDraftPref({ agent, providerId, modelId: model, active: false, ...patch }).catch(() => undefined); }), diff --git a/apps/mobile/src/__tests__/draftModelMemory.test.ts b/apps/mobile/src/__tests__/draftModelMemory.test.ts index 5229734fc7f..8c59def3706 100644 --- a/apps/mobile/src/__tests__/draftModelMemory.test.ts +++ b/apps/mobile/src/__tests__/draftModelMemory.test.ts @@ -115,3 +115,21 @@ describe('draftModelMemory', () => { expect(mem.getEffort('codex', 'openai', 'gpt-5.5')).toBeUndefined(); }); }); + + +it('reset deletes persisted overrides, retains other devices, and preserves values on a failed write', async () => { + store.clear(); + const mod = await load(); await mod.hydrateDraftModelMemory(); + const mem = mod.draftModelMemoryFor('a'); const other = mod.draftModelMemoryFor('b'); + mem.setEffort('codex', 'api', 'm', 'high'); mem.setFast('codex', 'api', 'm', true); + other.setEffort('codex', 'api', 'm', 'low'); + const storage = (await import('@react-native-async-storage/async-storage')).default; + vi.mocked(storage.setItem).mockRejectedValueOnce(new Error('disk-full')); + await expect(mem.reset!('codex', 'api', 'm')).rejects.toThrow('disk-full'); + expect(mem.getEffort('codex', 'api', 'm')).toBe('high'); + await mem.reset!('codex', 'api', 'm'); + mod.__resetForTest(); await mod.hydrateDraftModelMemory(); + expect(mem.getEffort('codex', 'api', 'm')).toBeUndefined(); + expect(mem.getFast('codex', 'api', 'm')).toBeUndefined(); + expect(other.getEffort('codex', 'api', 'm')).toBe('low'); +}); diff --git a/apps/mobile/src/__tests__/sessionModelMirror.test.ts b/apps/mobile/src/__tests__/sessionModelMirror.test.ts index b26c3fb22db..6d1ba4f84fe 100644 --- a/apps/mobile/src/__tests__/sessionModelMirror.test.ts +++ b/apps/mobile/src/__tests__/sessionModelMirror.test.ts @@ -77,3 +77,16 @@ describe('sessionModelMirror', () => { expect(acc.getEffort('codex', 'xd', 'm')).toBeUndefined(); }); }); + + +it('does not report remote reset success until the host acknowledges persistence', async () => { + __resetForTest(); + const old = makeSessionMirrorAccessors('reset', () => undefined); + old.setEffort('codex', 'api', 'm', 'high'); old.setFast('codex', 'api', 'm', true); + await expect(old.reset!('codex', 'api', 'm')).rejects.toThrow('UNSUPPORTED'); + expect(old.getEffort('codex', 'api', 'm')).toBe('high'); + const confirmed = makeSessionMirrorAccessors('reset', async () => ({ resetApplied: true })); + await confirmed.reset!('codex', 'api', 'm'); + expect(confirmed.getEffort('codex', 'api', 'm')).toBeUndefined(); + expect(confirmed.getFast('codex', 'api', 'm')).toBeUndefined(); +}); diff --git a/apps/mobile/src/device-link/mobileMakerTransport.ts b/apps/mobile/src/device-link/mobileMakerTransport.ts index 120fbe5dfa3..06bbe8ddb5d 100644 --- a/apps/mobile/src/device-link/mobileMakerTransport.ts +++ b/apps/mobile/src/device-link/mobileMakerTransport.ts @@ -475,6 +475,7 @@ export interface MobileMakerTransport { setSessionModelPref(pref: MobileSessionModelPref): Promise; /** 草稿「模型 effort/fast」写穿(active 恒 false;老被控端 → 调用方吞掉降级)。 */ applyNewMakerDraftPref(pref: MobileNewMakerDraftPref): Promise; + resetModelMemory(pref: { agent: MobileNewMakerDraftPref['agent']; providerId: string; modelId: string }): Promise<{ resetApplied: true }>; /** 工作端 New Maker 草稿默认值镜像(只读;手机当前只消费 worktreeEnabled)。 */ getNewMakerDefaults(agentKind: MobileAgentKind): Promise; /** 「新建会话默认启用 worktree」写穿工作端(老被控端 → 调用方吞掉降级)。 */ @@ -753,6 +754,7 @@ export function createMobileMakerTransport({ getApiKeyPresent: () => call('maker:api-key:present'), setSessionModelPref: (pref) => call('maker:set-session-model-pref', [pref]), applyNewMakerDraftPref: (pref) => call('maker:apply-new-maker-draft-pref', [pref]), + resetModelMemory: (pref) => call('maker:apply-new-maker-draft-pref', [{ ...pref, active: false, reset: ['effort', 'fast'] }]), getNewMakerDefaults: (agentKind) => call('maker:get-new-maker-defaults', [agentKind]), applyNewMakerWorktreePref: (worktreeEnabled) => call('maker:apply-new-maker-worktree-pref', [{ worktreeEnabled }]), diff --git a/apps/mobile/src/i18n/locales/en/models.json b/apps/mobile/src/i18n/locales/en/models.json index 29386cc2955..6ecd6c3403e 100644 --- a/apps/mobile/src/i18n/locales/en/models.json +++ b/apps/mobile/src/i18n/locales/en/models.json @@ -37,7 +37,10 @@ "xhigh": "Extra High", "max": "Maximum", "ultra": "Ultra" - } + }, + "resetDefaults": "Follow defaults next time", + "resetDefaultsHint": "Clears saved model preferences. The current task keeps its active settings.", + "resetDefaultsError": "Reset was not confirmed. Check the desktop connection and version, then retry." }, "contextWindowSwitch": { "title": "This model can't fit the current conversation", diff --git a/apps/mobile/src/i18n/locales/ja/models.json b/apps/mobile/src/i18n/locales/ja/models.json index 9d5de4816b0..09dcdc66887 100644 --- a/apps/mobile/src/i18n/locales/ja/models.json +++ b/apps/mobile/src/i18n/locales/ja/models.json @@ -37,7 +37,10 @@ "xhigh": "超高", "max": "最高", "ultra": "究極" - } + }, + "resetDefaults": "次回は既定値を使用", + "resetDefaultsHint": "保存したモデル設定を削除します。現在のタスクの設定は変更しません。", + "resetDefaultsError": "リセットを確認できませんでした。PC の接続とバージョンを確認して再試行してください。" }, "contextWindowSwitch": { "title": "このモデルには現在の会話が収まりません", diff --git a/apps/mobile/src/i18n/locales/ko/models.json b/apps/mobile/src/i18n/locales/ko/models.json index 4cf288da68b..4a1773e89eb 100644 --- a/apps/mobile/src/i18n/locales/ko/models.json +++ b/apps/mobile/src/i18n/locales/ko/models.json @@ -37,7 +37,10 @@ "xhigh": "초고", "max": "최고", "ultra": "극한" - } + }, + "resetDefaults": "다음부터 기본값 사용", + "resetDefaultsHint": "저장된 모델 기본 설정을 삭제합니다. 현재 작업의 실행 설정은 유지됩니다.", + "resetDefaultsError": "초기화를 확인하지 못했습니다. PC 연결과 버전을 확인한 후 다시 시도하세요." }, "contextWindowSwitch": { "title": "이 모델에는 현재 대화가 모두 들어가지 않습니다", diff --git a/apps/mobile/src/i18n/locales/zh-CN/models.json b/apps/mobile/src/i18n/locales/zh-CN/models.json index b9b59ff6723..f45c4c3ffb7 100644 --- a/apps/mobile/src/i18n/locales/zh-CN/models.json +++ b/apps/mobile/src/i18n/locales/zh-CN/models.json @@ -37,7 +37,10 @@ "xhigh": "超高", "max": "最高", "ultra": "极致" - } + }, + "resetDefaults": "下次使用跟随默认", + "resetDefaultsHint": "清除已保存的模型偏好,当前任务的运行配置保持不变。", + "resetDefaultsError": "未能确认重置。请检查电脑连接和版本后重试。" }, "contextWindowSwitch": { "title": "这个模型装不下当前对话", diff --git a/apps/mobile/src/i18n/locales/zh-TW/models.json b/apps/mobile/src/i18n/locales/zh-TW/models.json index aa22497d26f..4b913cd919a 100644 --- a/apps/mobile/src/i18n/locales/zh-TW/models.json +++ b/apps/mobile/src/i18n/locales/zh-TW/models.json @@ -37,7 +37,10 @@ "xhigh": "超高", "max": "最高", "ultra": "極致" - } + }, + "resetDefaults": "下次使用跟隨預設值", + "resetDefaultsHint": "清除已儲存的模型偏好,目前任務的執行設定保持不變。", + "resetDefaultsError": "未能確認重設。請檢查電腦連線和版本後重試。" }, "contextWindowSwitch": { "title": "此模型無法容納目前對話", diff --git a/apps/mobile/src/session/ModelOptionsSheetView.tsx b/apps/mobile/src/session/ModelOptionsSheetView.tsx index b3110e322fb..7c26407225e 100644 --- a/apps/mobile/src/session/ModelOptionsSheetView.tsx +++ b/apps/mobile/src/session/ModelOptionsSheetView.tsx @@ -8,6 +8,7 @@ * **非选中行** → 写注入记忆(草稿 = draftModelMemory / 会话 = sessionModelMirror 写穿被控端)。 * effort 点击后停留(可连续调 Fast),返回/把手下拉由浮窗层负责。 */ +import { useState } from "react"; import { Pressable, StyleSheet, View } from "react-native"; import { useTranslation } from "react-i18next"; import { Text } from "@/components/AppText"; @@ -163,6 +164,8 @@ export function ModelOptionsSheetView({ disabled = false, testID = "modelOptions", }: ModelOptionsSheetViewProps) { + const [resetting, setResetting] = useState(false); + const [resetError, setResetError] = useState(false); const styles = useThemedStyles(makeStyles); const { colors } = useTheme(); const { t } = useTranslation(); @@ -251,6 +254,14 @@ export function ModelOptionsSheetView({ ) : null} ) : null} + {modelMemory?.reset && providerId ? <> + { setResetting(true); setResetError(false); void modelMemory.reset!(agentKind, providerId, model.id).catch(() => setResetError(true)).finally(() => setResetting(false)); }}> + {t('models.options.resetDefaults')} + + {t('models.options.resetDefaultsHint')} + {resetError ? {t('models.options.resetDefaultsError')} : null} + : null} {fastEditable ? ( diff --git a/apps/mobile/src/session/draftModelMemory.ts b/apps/mobile/src/session/draftModelMemory.ts index a4ea5ce187b..135ade6acfa 100644 --- a/apps/mobile/src/session/draftModelMemory.ts +++ b/apps/mobile/src/session/draftModelMemory.ts @@ -26,6 +26,7 @@ const STORAGE_KEY = 'xdtm:draftModelMemory:v1'; * sessionModelMirror 的 accessors,组件本身不耦合具体存储。 */ export interface MobileModelMemoryAccessors { + reset?(agent: AgentKind, providerId: string, modelId: string): Promise; getEffort(agent: AgentKind, providerId: string, modelId: string): string | undefined; setEffort(agent: AgentKind, providerId: string, modelId: string, effort: string): void; getFast(agent: AgentKind, providerId: string, modelId: string): boolean | undefined; @@ -145,6 +146,23 @@ function getSlot(deviceId: string, agent: AgentKind, providerId: string, create: /** 取某被控设备的草稿记忆读写器(注入模型选择列表)。deviceId 空 → 全 no-op/undefined。 */ export function draftModelMemoryFor(deviceId: string): MobileModelMemoryAccessors { return { + reset: async (agent, providerId, modelId) => { + await hydrateDraftModelMemory(); + if (!deviceId || !providerId || !modelId) return; + const slot = getSlot(deviceId, agent, providerId, false); + if (!slot) return; + const previousEffort = slot.effortByModel[modelId]; + const previousFast = slot.fastByModel[modelId]; + delete slot.effortByModel[modelId]; delete slot.fastByModel[modelId]; + try { await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(cache)); } + catch (error) { + // Retain concurrent edits, and restore only keys still absent from this attempted reset. + if (slot.effortByModel[modelId] === undefined && previousEffort !== undefined) slot.effortByModel[modelId] = previousEffort; + if (slot.fastByModel[modelId] === undefined && previousFast !== undefined) slot.fastByModel[modelId] = previousFast; + emit(); throw error; + } + emit(); + }, getEffort: (agent, providerId, modelId) => { if (!deviceId || !providerId || !modelId) return undefined; return getSlot(deviceId, agent, providerId, false)?.effortByModel[modelId]; diff --git a/apps/mobile/src/session/modelPickerRows.ts b/apps/mobile/src/session/modelPickerRows.ts index 11fe893e518..05c578a43d5 100644 --- a/apps/mobile/src/session/modelPickerRows.ts +++ b/apps/mobile/src/session/modelPickerRows.ts @@ -32,6 +32,7 @@ export function budgetDisabledHint(): string { /** 行/展开区消费的最小模型形状(SectionModel 与 capabilities MobileModelOption 都满足)。 */ export interface PickerRowModel { + defaultFast?: boolean; id: string; efforts: readonly string[]; defaultEffort: string | null; @@ -360,7 +361,7 @@ export function rowFastOn(args: { if (!args.fastEditable) return false; if (args.selected) return args.liveFastMode; if (!args.agentKind || !args.providerId) return false; - return args.memory?.getFast(args.agentKind, args.providerId, args.model.id) ?? false; + return args.memory?.getFast(args.agentKind, args.providerId, args.model.id) ?? args.model.defaultFast ?? false; } /** diff --git a/apps/mobile/src/session/providerModelSections.ts b/apps/mobile/src/session/providerModelSections.ts index ee857fa1adf..e779ab84a6e 100644 --- a/apps/mobile/src/session/providerModelSections.ts +++ b/apps/mobile/src/session/providerModelSections.ts @@ -247,7 +247,7 @@ export function resolveRowSelection(args: { const fastEditable = args.hasFastModeCap && modelSupportsFastMode(row.provider, row.model.id, agentKind); const fastMode = fastEditable - ? memory?.getFast(agentKind, row.provider.id, row.model.id) ?? false + ? memory?.getFast(agentKind, row.provider.id, row.model.id) ?? row.model.defaultFast ?? false : false; return { model: row.model.id, providerId: row.provider.id, effort, fastMode }; diff --git a/apps/mobile/src/session/sessionModelMirror.ts b/apps/mobile/src/session/sessionModelMirror.ts index 547c13c68bb..95656b99f41 100644 --- a/apps/mobile/src/session/sessionModelMirror.ts +++ b/apps/mobile/src/session/sessionModelMirror.ts @@ -124,10 +124,17 @@ export function makeSessionMirrorAccessors( agent: AgentKind, providerId: string, model: string, - patch: { effort?: string; fast?: boolean }, - ) => void, -): MobileModelMemoryAccessors { + patch: { effort?: string; fast?: boolean; reset?: ('effort' | 'fast')[] }, + ) => unknown | Promise, + ): MobileModelMemoryAccessors { return { + reset: async (agent, providerId, model) => { + const result = await onWrite(agent, providerId, model, { reset: ['effort', 'fast'] }); + if (!result || typeof result !== 'object' || (result as { resetApplied?: unknown }).resetApplied !== true) throw new Error('MODEL_MEMORY_RESET_UNSUPPORTED'); + const slot = getSlot(sessionId, agent, providerId, false); + if (slot) { delete slot.effortByModel[model]; delete slot.fastByModel[model]; } + emit(); + }, getEffort: (agent, providerId, model) => getSlot(sessionId, agent, providerId, false)?.effortByModel[model], getFast: (agent, providerId, model) => diff --git a/docs/dev-rules/model-catalog-maintenance.md b/docs/dev-rules/model-catalog-maintenance.md index 9e7c7671368..77d110ac01f 100644 --- a/docs/dev-rules/model-catalog-maintenance.md +++ b/docs/dev-rules/model-catalog-maintenance.md @@ -9,6 +9,19 @@ ## 1. 先找数据归属 +**数据库发布扩展(2026-09-10)**:完成显式切换后,公共配置在 Cindy Platform 模型管理页面编辑草稿并发布。CN 数据库主写,Global 的 `MODEL_CATALOG_URL` 指向 CN `/api/model-catalog/catalog`,在区域数据库缓存同一发布版本。下面关于 Server 文件维护的操作仅适用于尚未切换的部署,不再用于日常配置更新。 + +客户端请求 Registry V5;Desktop 失败时使用当前端点缓存和 bundled,不再默认请求旧 OSS。Registry V5 产品默认与用户覆盖的关系见 [模型资料优先级](../product-rules/model-metadata-precedence.md)。显示开关仍遵守首次初始化例外,不随发布扩展已初始化清单。 + +发布后导出离线构建产物: + +```sh +pnpm exec tsx packages/model-providers/scripts/exportPublishedCatalog.ts \ + https://model-access.cindy.com.cn/api/model-catalog/catalog /path/to/new-export +``` + +脚本校验 V5、限制响应大小并拒绝覆盖输出;审阅后将完整 providers 与 registry 放入 bundled。Pi 原生快照仍跟随 harness,不用订阅目录重建。没有部署及发布的新功能不得伪装成已发布 revision。 + | 内容 | 应维护的位置 | 客户端职责 | | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | 统一模型目录中的名称、窗口、最大输出、推理档位、参考价及默认标记 | `cindy-server` 的 `model-access-server/catalog/providers.json`;若部署配置了 `MODEL_CATALOG_URL`,还需核对那份远程完整快照 | 同步 `packages/model-providers/catalog/model-registry.json` 作为内置兜底,正确消费实际下发值 | @@ -40,7 +53,7 @@ Provider 的媒体数组是现有消费接口,由 V4 资料投影;用户成 客户端入口是 `packages/model-providers/src/source.ts` 的 `loadCatalog`,Desktop 由 `apps/desktop/src/main/maker-host/createDesktopProviderService.ts` 装载并刷新活动目录。 公共目录接口为当前配置的 `modelAccessApiBaseUrl` 下的 `/api/model-catalog/catalog`; -还可能存在本地覆盖、上一份有效缓存及旧 OSS 兼容来源。不要仅凭文件名或注释认定当前来源。 +还可能存在本地覆盖、上一份有效缓存及随包兜底。旧 OSS 仅保留显式工具配置兼容;Desktop 不默认使用。不要仅凭文件名或注释认定当前来源。 - Server 仓库文件是随制品发布的基线。改文件不等于部署完成;配置了远程覆盖源时, 还必须检查远程源是否会覆盖该基线。Server 的实际行为以其当前代码、部署配置和接口为准。 diff --git a/docs/dev-rules/protocol-compatibility.md b/docs/dev-rules/protocol-compatibility.md index e229f1c08f0..e269350529e 100644 --- a/docs/dev-rules/protocol-compatibility.md +++ b/docs/dev-rules/protocol-compatibility.md @@ -179,3 +179,6 @@ Seed 2.1 Pro 按火山方舟官方示例选择 Chat Completions 为 Cindy 的标准接入协议, 依据与全路由覆盖验收见 model-catalog-maintenance.md。 价格、窗口、推理档位不随此次协议补全修改;协议默认开启策略仍保留用户显式覆盖。 +# 模型默认重置的增量兼容(2026-09-10) + +沿用 `APPLY_NEW_MAKER_DRAFT_PREF`,增加 `reset: ('effort' | 'fast')[]`,只允许 `active: false` 且不混入赋值字段。主机发送带 request ID 和数据 owner 的指令;renderer 核对 owner、删除覆盖并落盘后,经已有 `SYNC_PROVIDER_MODEL_MEMORY` 回执,调用返回 `{ resetApplied: true }`。旧主机无回执时保留控制端镜像,不能把写入当前默认快照当成删除。普通旧字段及通道名保持不变。 diff --git a/docs/product-rules/model-metadata-precedence.md b/docs/product-rules/model-metadata-precedence.md index 0994312dd69..05a987615b3 100644 --- a/docs/product-rules/model-metadata-precedence.md +++ b/docs/product-rules/model-metadata-precedence.md @@ -89,9 +89,15 @@ ## 发布、兼容与验证 -Server `catalog/providers.json` 是数据正本;客户端 `catalog/model-registry.json` 是同 revision、同内容的离线副本。`baseModels`、模型引用和本地域必须随整个 Registry 一起校验、发布和同步,禁止只复制子域造成悬空引用。坏快照、网络失败、回退 revision 和同 revision 冲突沿用上一份合法快照。 +服务端显式切换后,Model Access 数据库的已发布版本是数据正本。CN 主写,Global 经公共 API 读取并持久化缓存;外部模板共用,Cindy AI 成员与实价保持区域权威。原 Server JSON 仅用于首次迁移,客户端 bundled 是已发布版本的离线构建产物。`baseModels`、模型引用和本地域必须整体校验、发布和同步;坏快照、回退 revision 和同 revision 冲突沿用上一份合法快照。 -复用当前目录接口与 Registry V4 协商,不增加请求或数据库表。旧 V1/V2/V3 客户端收到展开后的旧字段;不同资料的多条路由在兼容响应中拆成独立条目,保留上游 ID,额外条目使用派生目录 ID。旧协议不支持的公共引用、覆盖指令与图片字段被移除,空默认以缺省表达。 +复用当前公共目录接口并协商 Registry V5;服务端增加模型域数据库表,版本 1–4 使用兼容投影。旧 V1/V2/V3 客户端收到展开后的旧字段,保留上游 ID;不支持的公共引用、覆盖指令与图片字段被移除,空默认以缺省表达。 + +V5 的 `productDefaults` 独立保存默认使用窗口、思考档、Fast、显示默认和推荐引擎;`perAgent` 保存已适配引擎的差异。这些产品选择在能力解析后、用户覆盖前应用,不增加能力或模型成员。订阅、API、Cindy AI 分开配置;共享上游 ID 的 `[1m]` 配置按具体目录 ID 区分。模板模型的产品默认只由端点、协议和路由仍匹配的 `catalogPresetId` 连接继承。 + +显示默认遵守 `configuration-and-overrides.md` 的可见性例外:已初始化清单不随发布扩展或重置;新用户首次初始化及用户明确恢复默认的路线采用当前目录默认。`disabledPresetIds` 只过滤添加入口,已有连接仍读取模板资料。 + +恢复思考 / Fast 默认删除对应覆盖。远程与 Mobile 等待主机 renderer 落盘确认;旧主机或保存失败不能虚报成功。目录刷新不直接改写活动任务;用户主动恢复时遵守所在界面的任务操作语义。 供应商总容量与客户端工作预算分开:既有 GPT 272K 工作预算继续保留,实际容量放在 `contextWindowMax`;用户显式窗口覆盖仍优先。协议和实际执行能力门禁继续由运行时负责,元数据不能解锁未实现协议或伪造可售性。 diff --git a/packages/model-providers/scripts/exportPublishedCatalog.ts b/packages/model-providers/scripts/exportPublishedCatalog.ts new file mode 100644 index 00000000000..c4f5e5be418 --- /dev/null +++ b/packages/model-providers/scripts/exportPublishedCatalog.ts @@ -0,0 +1,57 @@ +/** Generate reviewed offline build artifacts from a published API, never edit a second server source. */ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { parseCatalog } from '../src/catalog.js'; + +const [source, directory] = process.argv.slice(2); +if (!source || !directory) + throw new Error( + 'Usage: tsx exportPublishedCatalog.ts ', + ); +const url = new URL(source); +if (url.protocol !== 'https:' || url.username || url.password) + throw new Error('Expected a public HTTPS catalog URL'); +url.searchParams.set('registrySchemaVersion', '5'); +const response = await fetch(url, { + redirect: 'error', + signal: AbortSignal.timeout(15000), +}); +if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`); +const reader = response.body?.getReader(); +if (!reader) throw new Error('Catalog is empty'); +const chunks: Uint8Array[] = []; +let size = 0; +try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 2000000) { + await reader.cancel(); + throw new Error('Catalog exceeds 2 MB'); + } + chunks.push(value); + } +} finally { + reader.releaseLock(); +} +const body = Buffer.concat(chunks).toString('utf8'); +const catalog = parseCatalog(body); +if (catalog.modelRegistry?.schemaVersion !== 5) + throw new Error('Source has not published Registry V5'); +const raw = JSON.parse(body); +const { modelRegistry, ...providers } = raw; +await mkdir(directory, { recursive: true }); +for (const [name, value] of [ + ['providers.json', providers], + ['model-registry.json', modelRegistry], +] as const) { + await writeFile( + join(directory, name), + JSON.stringify(value, null, 2) + '\n', + { flag: 'wx' }, + ); +} +process.stdout.write( + `Exported ${catalog.version}. Review the diff before replacing the bundled offline artifacts. Pi native catalog remains pinned to its harness version.\n`, +); diff --git a/packages/model-providers/src/__tests__/modelCatalogPolicy.test.ts b/packages/model-providers/src/__tests__/modelCatalogPolicy.test.ts new file mode 100644 index 00000000000..7813d9c27ff --- /dev/null +++ b/packages/model-providers/src/__tests__/modelCatalogPolicy.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { + applyModelProductDefaults, + resolveModelProductDefaults, + validModelProductDefaults, +} from '../modelCatalogPolicy.js'; +import { buildUserProvider } from '../user-provider.js'; +import type { + CatalogModel, + CustomProviderConfig, + ProviderPreset, +} from '../types.js'; +import type { ModelRegistry } from '../modelAccessBean.js'; + +describe('published model defaults', () => { + it('distinguishes product identity, channel and engine despite duplicate legacy upstream IDs', () => { + const registry: ModelRegistry = { + schemaVersion: 5, + updatedAt: '2026-09-10T00:00:00Z', + models: [ + { + id: 'openai/gpt-test', + name: 'Test', + routes: [ + { providerId: 'openai', modelId: 'gpt-test', agents: ['codex'] }, + ], + productDefaults: { + contextWindow: 272000, + fast: false, + perAgent: { pi: { effort: 'low' } }, + }, + }, + { + id: 'openai/gpt-test[1m]', + name: 'Test', + routes: [ + { providerId: 'openai', modelId: 'gpt-test', agents: ['codex'] }, + ], + productDefaults: { contextWindow: 1000000 }, + }, + { + id: 'xd/gpt-test', + name: 'Test', + routes: [ + { providerId: 'xd', modelId: 'gpt-test', agents: ['codex'] }, + ], + productDefaults: { contextWindow: 200000 }, + }, + ], + }; + expect( + resolveModelProductDefaults( + registry, + 'openai', + 'chatgpt/gpt-test', + 'codex', + )?.contextWindow, + ).toBe(272000); + expect( + resolveModelProductDefaults(registry, 'openai', 'gpt-test[1m]', 'codex') + ?.contextWindow, + ).toBe(1000000); + expect( + resolveModelProductDefaults(registry, 'openai', 'gpt-test', 'pi')?.effort, + ).toBe('low'); + expect( + resolveModelProductDefaults(registry, 'xd', 'gpt-test', 'codex') + ?.contextWindow, + ).toBe(200000); + expect( + resolveModelProductDefaults(registry, 'my-api', 'gpt-test', 'codex'), + ).toBeUndefined(); + }); + it('preserves maximum capacity, explicit false/null and never invents Fast support', () => { + const model: CatalogModel = { + id: 'test', + name: 'Test', + contextWindow: 1000000, + efforts: ['low', 'high'], + defaultEffort: 'high', + }; + const result = applyModelProductDefaults( + model, + { contextWindow: 272000, visible: false, effort: null, fast: true }, + 'catalog-7', + ); + expect(result).toMatchObject({ + contextWindow: 272000, + contextWindowMax: 1000000, + defaultEnabled: false, + defaultEffort: null, + defaultFast: false, + catalogDefaults: { revision: 'catalog-7' }, + }); + expect(model.contextWindow).toBe(1000000); + expect(validModelProductDefaults({ fast: false, effort: null })).toBe(true); + expect(validModelProductDefaults({ fast: 'false' })).toBe(false); + expect( + validModelProductDefaults({ + perAgent: { codex: { preferredAgent: 'pi' } }, + }), + ).toBe(false); + }); + it('updates a matching API template without replacing form edits or following a changed endpoint', () => { + const config: CustomProviderConfig = { + id: 'personal', + name: 'Personal', + runtimes: { + pi: { + catalogPresetId: 'official-api', + baseUrl: 'https://api.example/v1', + wireProtocol: 'openai-chat', + models: [{ id: 'm', name: 'M' }], + }, + }, + }; + const presets: ProviderPreset[] = [ + { + id: 'official-api', + name: 'API', + runtimes: { + pi: { + baseUrl: 'https://api.example/v1', + wireProtocol: 'openai-chat', + models: [ + { + id: 'm', + name: 'M', + contextWindow: 1000000, + reasoning: true, + reasoningEfforts: ['low', 'high'], + productDefaults: { + contextWindow: 272000, + effort: 'low', + visible: false, + }, + }, + ], + }, + }, + }, + ]; + const current = () => + buildUserProvider(config, { + presets, + catalogRevision: 'catalog-9', + modelRegistry: { + schemaVersion: 5, + updatedAt: '2026-09-10T00:00:00Z', + models: [], + }, + }).models.pi![0]; + expect(current()).toMatchObject({ + contextWindow: 272000, + contextWindowMax: 1000000, + defaultEffort: 'low', + defaultEnabled: false, + catalogDefaults: { revision: 'catalog-9' }, + }); + config.runtimes.pi!.models[0].contextWindow = 500000; + config.runtimes.pi!.models[0].defaultEnabled = true; + expect(current()).toMatchObject({ + contextWindow: 500000, + defaultEnabled: true, + }); + config.runtimes.pi!.baseUrl = 'https://different.example/v1'; + expect(current().catalogDefaults).toBeUndefined(); + }); +}); diff --git a/packages/model-providers/src/__tests__/source-registry.test.ts b/packages/model-providers/src/__tests__/source-registry.test.ts index 2a2c5d2f5bf..68e7bf53175 100644 --- a/packages/model-providers/src/__tests__/source-registry.test.ts +++ b/packages/model-providers/src/__tests__/source-registry.test.ts @@ -115,7 +115,7 @@ describe("resolveCatalogUrl", () => { expect( resolveCatalogUrl({ baseUrl: "https://model-access.example.com/" }), ).toBe( - "https://model-access.example.com/api/model-catalog/catalog?registrySchemaVersion=4", + "https://model-access.example.com/api/model-catalog/catalog?registrySchemaVersion=5", ); }); it("builds the migration OSS fallback URL", () => { @@ -1042,7 +1042,7 @@ describe("loadCatalog", () => { ); expect(fetchText).toHaveBeenNthCalledWith( 1, - "https://model-access.example.com/api/model-catalog/catalog?registrySchemaVersion=4", + "https://model-access.example.com/api/model-catalog/catalog?registrySchemaVersion=5", 15_000, ); expect(fetchText).toHaveBeenNthCalledWith( @@ -1102,7 +1102,7 @@ describe("loadCatalog", () => { ); expect(fetchText).toHaveBeenNthCalledWith( 1, - "https://model-access.example.com/api/model-catalog/catalog?registrySchemaVersion=4", + "https://model-access.example.com/api/model-catalog/catalog?registrySchemaVersion=5", 15_000, ); expect(fetchText).toHaveBeenNthCalledWith( @@ -1160,7 +1160,7 @@ describe("loadCatalog", () => { ); expect(fetchText).toHaveBeenNthCalledWith( 1, - "https://model-access.example.com/api/model-catalog/catalog?registrySchemaVersion=4", + "https://model-access.example.com/api/model-catalog/catalog?registrySchemaVersion=5", 15_000, ); expect(fetchText).toHaveBeenNthCalledWith( @@ -1189,7 +1189,7 @@ describe("loadCatalog", () => { ); expect(fetchText).toHaveBeenCalledTimes(1); expect(fetchText).toHaveBeenCalledWith( - "https://model-access.example.com/api/model-catalog/catalog?registrySchemaVersion=4", + "https://model-access.example.com/api/model-catalog/catalog?registrySchemaVersion=5", 15_000, ); expect(cat.version).toBe(BUNDLED_CATALOG.version); diff --git a/packages/model-providers/src/catalog.ts b/packages/model-providers/src/catalog.ts index 112b89b2542..d3e6d2b027b 100644 --- a/packages/model-providers/src/catalog.ts +++ b/packages/model-providers/src/catalog.ts @@ -1,3 +1,4 @@ +import { validModelProductDefaults } from "./modelCatalogPolicy.js"; /** * 目录运行时校验(parseCatalog)+ presets 清洗排序。 * @@ -558,6 +559,7 @@ function isValidPreset(v: unknown): v is ProviderPreset { return false; } if (!hasValidPresetReasoningCapability(agent, mm)) return false; + if (mm.productDefaults !== undefined && !validModelProductDefaults(mm.productDefaults)) return false; } if (r.wireProtocol !== undefined && !isWireProtocol(r.wireProtocol)) return false; if (agent === 'claude-code' && r.wireProtocol === 'openai-chat') return false; @@ -822,11 +824,11 @@ export function parseCatalog(input: string | unknown): Catalog { for (const provider of catalog.providers) { for (const field of ['imageModels', 'videoModels', 'audioModels', 'embeddingModels'] as const) validateMediaModels(provider.id, field, provider[field], field === 'audioModels' ? '' : field.replace('Models', 'Defaults'), - field === 'imageModels' ? provider.imageDefaults : field === 'videoModels' ? provider.videoDefaults : field === 'embeddingModels' ? provider.embeddingDefaults : undefined, catalog.modelRegistry?.schemaVersion === 4); + field === 'imageModels' ? provider.imageDefaults : field === 'videoModels' ? provider.videoDefaults : field === 'embeddingModels' ? provider.embeddingDefaults : undefined, [4, 5].includes(catalog.modelRegistry?.schemaVersion ?? 0)); } const projected = { ...catalog, providers: catalog.providers.map((provider) => projectProviderMediaModels(provider, catalog.modelRegistry, { addDeclared: true })) }; - for (const provider of projected.providers) validateProvider(provider, catalog.modelRegistry?.schemaVersion === 4); + for (const provider of projected.providers) validateProvider(provider, [4, 5].includes(catalog.modelRegistry?.schemaVersion ?? 0)); validateModelConsistency(projected); // 远端下发目录与 bundled 同格式:静态条目的窗口是产品侧写定的真实上限,标记为已核实 // (幂等;条目自己表过态时尊重原值)。动态发现的模型不经这里 —— 见 withVerifiedStaticWindows。 diff --git a/packages/model-providers/src/index.ts b/packages/model-providers/src/index.ts index 572a491d357..d811c68579d 100644 --- a/packages/model-providers/src/index.ts +++ b/packages/model-providers/src/index.ts @@ -290,3 +290,6 @@ export type { DiscoveredModel } from "./modelMetadataLayers.js"; export { runtimeUserModelMetadata } from "./modelMetadataLayers.js"; export { PROVIDER_MEDIA_FIELDS, providerMediaField, projectProviderMediaModels } from "./providerMediaModels.js"; + +export { applyModelProductDefaults, resolveModelProductDefaults } from "./modelCatalogPolicy.js"; +export type { ModelProductDefaults } from "./modelCatalogPolicy.js"; diff --git a/packages/model-providers/src/modelAccessBean.ts b/packages/model-providers/src/modelAccessBean.ts index be945ccfd1e..2d55895ab72 100644 --- a/packages/model-providers/src/modelAccessBean.ts +++ b/packages/model-providers/src/modelAccessBean.ts @@ -1,3 +1,4 @@ +import type { ModelProductDefaults } from "./modelCatalogPolicy.js"; import type { BaseModel, ModelMetadata } from "./modelMetadataLayers.js"; import type { LocalModelCatalog } from "./localModelCatalog.js"; /** @@ -56,6 +57,7 @@ export const MODEL_REGISTRY_LEGACY_SCHEMA_VERSION = 1 as const; export const MODEL_REGISTRY_SCHEMA_VERSION = 2 as const; export const MODEL_REGISTRY_V3_SCHEMA_VERSION = 3 as const; export const MODEL_REGISTRY_V4_SCHEMA_VERSION = 4 as const; +export const MODEL_REGISTRY_V5_SCHEMA_VERSION = 5 as const; export const MODEL_NATIVE_APIS = [ "anthropic-messages", "openai-responses", @@ -160,6 +162,7 @@ export interface ModelRegistryEntry extends Pick, Omit { + productDefaults?: ModelProductDefaults; /** V4 entry-level image capability default. */ supportsImageInput?: boolean; /** V4 only: null explicitly clears an inherited default. */ @@ -185,7 +188,10 @@ export interface ModelRegistry extends ModelRegistryBase { | typeof MODEL_REGISTRY_LEGACY_SCHEMA_VERSION | typeof MODEL_REGISTRY_SCHEMA_VERSION | typeof MODEL_REGISTRY_V3_SCHEMA_VERSION - | typeof MODEL_REGISTRY_V4_SCHEMA_VERSION; + | typeof MODEL_REGISTRY_V4_SCHEMA_VERSION + | typeof MODEL_REGISTRY_V5_SCHEMA_VERSION; + /** Explicit template withdrawal; omission never withdraws native Pi models. */ + disabledPresetIds?: string[]; baseModels?: BaseModel[]; localModels?: LocalModelCatalog; models: ModelRegistryEntry[]; diff --git a/packages/model-providers/src/modelAccessValidator.ts b/packages/model-providers/src/modelAccessValidator.ts index ad9d83e9216..1f2a5f171f0 100644 --- a/packages/model-providers/src/modelAccessValidator.ts +++ b/packages/model-providers/src/modelAccessValidator.ts @@ -1,3 +1,4 @@ +import { validModelProductDefaults } from "./modelCatalogPolicy.js"; import { validModelMetadata, expandedRegistryEntries, @@ -19,6 +20,7 @@ import { MODEL_REGISTRY_SCHEMA_VERSION, MODEL_REGISTRY_V3_SCHEMA_VERSION, MODEL_REGISTRY_V4_SCHEMA_VERSION, + MODEL_REGISTRY_V5_SCHEMA_VERSION, MODEL_NATIVE_APIS, MODEL_REGISTRY_STATUSES, type ListModelsResponse, @@ -1063,6 +1065,7 @@ function registryEntryError( : schemaVersion >= MODEL_REGISTRY_V3_SCHEMA_VERSION ? [ ...MODEL_REGISTRY_ENTRY_V2_FIELDS, + ...(schemaVersion >= 5 ? ["productDefaults"] : []), "nativeApi", ...(schemaVersion >= 4 ? [ @@ -1234,12 +1237,13 @@ export function parseModelRegistry( if (!isPlainObject(value)) return fail("modelRegistry must be an object"); const unknownField = unknownFieldError( value, - value.schemaVersion === MODEL_REGISTRY_V4_SCHEMA_VERSION + Number(value.schemaVersion) >= MODEL_REGISTRY_V4_SCHEMA_VERSION ? [ ...MODEL_REGISTRY_FIELDS, "nativeApiRules", "localModels", "baseModels", + ...(value.schemaVersion === 5 ? ["disabledPresetIds"] : []), ] : value.schemaVersion === MODEL_REGISTRY_V3_SCHEMA_VERSION ? [...MODEL_REGISTRY_FIELDS, "nativeApiRules"] @@ -1251,9 +1255,10 @@ export function parseModelRegistry( value.schemaVersion !== MODEL_REGISTRY_LEGACY_SCHEMA_VERSION && value.schemaVersion !== MODEL_REGISTRY_SCHEMA_VERSION && value.schemaVersion !== MODEL_REGISTRY_V3_SCHEMA_VERSION && - value.schemaVersion !== MODEL_REGISTRY_V4_SCHEMA_VERSION + value.schemaVersion !== MODEL_REGISTRY_V4_SCHEMA_VERSION && + value.schemaVersion !== MODEL_REGISTRY_V5_SCHEMA_VERSION ) { - return fail("modelRegistry.schemaVersion must be 1, 2, 3 or 4"); + return fail("modelRegistry.schemaVersion must be 1, 2, 3, 4 or 5"); } if (!isIsoTimestamp(value.updatedAt)) { return fail("modelRegistry.updatedAt must be an ISO timestamp"); @@ -1350,8 +1355,10 @@ export function parseModelRegistry( ) ) return fail("modelRegistry.localModels.modelRef is unresolved"); + if (value.disabledPresetIds !== undefined && (!Array.isArray(value.disabledPresetIds) || value.disabledPresetIds.length > 2000 || value.disabledPresetIds.some((id) => typeof id !== "string" || !id || id.length > 256) || new Set(value.disabledPresetIds).size !== value.disabledPresetIds.length)) return fail("modelRegistry.disabledPresetIds is invalid"); const modelIds = new Set(); for (const [index, model] of value.models.entries()) { + if (isPlainObject(model) && model.productDefaults !== undefined && (value.schemaVersion !== 5 || !validModelProductDefaults(model.productDefaults))) return fail(`modelRegistry.models[${index}].productDefaults is invalid`); if (isPlainObject(model) && typeof model.id === "string") { if (modelIds.has(model.id)) { return fail(`modelRegistry.models[${index}].id must be unique`); diff --git a/packages/model-providers/src/modelCatalogPolicy.ts b/packages/model-providers/src/modelCatalogPolicy.ts new file mode 100644 index 00000000000..5f346037d19 --- /dev/null +++ b/packages/model-providers/src/modelCatalogPolicy.ts @@ -0,0 +1,155 @@ +import type { CatalogModel } from './types.js'; +import { clampEffortToSupported } from './effortResolution.js'; +import type { + ModelAgent, + ModelEffort, + ModelRegistry, +} from './modelAccessBean.js'; + +/** Product choices, independent of vendor capabilities and user overrides. */ +export interface ModelProductDefaults { + visible?: boolean; + preferredAgent?: ModelAgent; + contextWindow?: number; + effort?: ModelEffort | null; + fast?: boolean; + perAgent?: Partial< + Record< + ModelAgent, + Omit + > + >; +} + +export function validModelProductDefaults( + value: unknown, + nested = false, +): value is ModelProductDefaults { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const v = value as Record; + const agents = ['claude-code', 'codex', 'pi']; + const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']; + const fields = [ + 'visible', + 'contextWindow', + 'effort', + 'fast', + ...(nested ? [] : ['preferredAgent', 'perAgent']), + ]; + if (Object.keys(v).some((key) => !fields.includes(key))) return false; + if ( + ['visible', 'fast'].some( + (key) => v[key] !== undefined && typeof v[key] !== 'boolean', + ) + ) + return false; + if ( + v.contextWindow !== undefined && + (!Number.isSafeInteger(v.contextWindow) || (v.contextWindow as number) <= 0) + ) + return false; + if ( + v.effort !== undefined && + v.effort !== null && + !efforts.includes(v.effort as string) + ) + return false; + if ( + v.preferredAgent !== undefined && + !agents.includes(v.preferredAgent as string) + ) + return false; + if (v.perAgent !== undefined) { + if ( + !v.perAgent || + typeof v.perAgent !== 'object' || + Array.isArray(v.perAgent) + ) + return false; + if ( + Object.entries(v.perAgent).some( + ([key, item]) => + !agents.includes(key) || !validModelProductDefaults(item, true), + ) + ) + return false; + } + return true; +} + +export function resolveModelProductDefaults( + registry: ModelRegistry | undefined, + providerId: string, + modelId: string, + agent: ModelAgent, +): ModelProductDefaults | undefined { + if (registry?.schemaVersion !== 5) return undefined; + const id = + providerId === 'openai' + ? modelId.replace(/^chatgpt\//, '').replace(/\[1m\]$/, '') + : providerId === 'xai' && !modelId.startsWith('xai/') + ? `xai/${modelId}` + : modelId; + const entries = registry.models.filter((entry) => + entry.routes.some( + (route) => route.providerId === providerId && route.modelId === id, + ), + ); + // Legacy [1m] profiles can share one upstream route. Prefer the exact product identity. + const exact = + entries.find( + (entry) => + entry.id === `${providerId}/${modelId.replace(/^chatgpt\//, '')}`, + ) ?? entries.find((entry) => entry.id === `${providerId}/${id}`); + const entry = exact ?? (entries.length === 1 ? entries[0] : undefined); + const defaults = entry?.productDefaults; + return defaults + ? { ...defaults, ...defaults.perAgent?.[agent], perAgent: undefined } + : undefined; +} + +/** Applied before personal overrides. Never persists resolved defaults as user choices. */ +export function applyModelProductDefaults( + model: CatalogModel, + defaults: ModelProductDefaults | undefined, + revision?: string, +): CatalogModel { + if (!defaults) return model; + const capacity = model.contextWindowMax ?? model.contextWindow; + return { + ...model, + ...(defaults.contextWindow !== undefined + ? { + contextWindow: Math.min(defaults.contextWindow, capacity), + contextWindowMax: capacity, + } + : {}), + ...(defaults.visible !== undefined + ? { defaultEnabled: defaults.visible } + : {}), + ...(defaults.effort !== undefined + ? { + defaultEffort: + defaults.effort === null || !model.efforts.length + ? null + : (clampEffortToSupported( + defaults.effort, + model.efforts, + ) as CatalogModel['defaultEffort']), + } + : {}), + ...(defaults.fast !== undefined + ? { defaultFast: defaults.fast && model.supportsFastMode === true } + : {}), + ...(defaults.preferredAgent !== undefined + ? { preferredAgent: defaults.preferredAgent } + : {}), + catalogDefaults: { + revision, + contextWindow: defaults.contextWindow, + effort: defaults.effort, + fast: defaults.fast, + visible: defaults.visible, + }, + }; +} diff --git a/packages/model-providers/src/modelRegistry.ts b/packages/model-providers/src/modelRegistry.ts index a7520a36b82..3a539e44cbd 100644 --- a/packages/model-providers/src/modelRegistry.ts +++ b/packages/model-providers/src/modelRegistry.ts @@ -200,7 +200,7 @@ export function findModelRegistryRoute( modelId, agent, )[0]; - if (!matched || registry?.schemaVersion !== 4) return matched; + if (!matched || !registry || ![4, 5].includes(registry.schemaVersion)) return matched; return { route: matched.route, entry: { diff --git a/packages/model-providers/src/sections.ts b/packages/model-providers/src/sections.ts index 9438447be22..f5a8a4affab 100644 --- a/packages/model-providers/src/sections.ts +++ b/packages/model-providers/src/sections.ts @@ -30,6 +30,7 @@ export function isModelVisible( /** 「按供应商分段」里每行模型的展示形状(从 catalog 派生,字段够渲染 + effort 配置用)。 */ export interface SectionModel { + defaultFast?: boolean; id: string; displayName: string; description?: string; @@ -180,6 +181,7 @@ export function buildProviderSections(args: { efforts: m.efforts, ...(m.displayEfforts ? { displayEfforts: m.displayEfforts } : {}), defaultEffort: m.defaultEffort, + defaultFast: m.defaultFast, contextWindow: m.contextWindow, }; if (m.description !== undefined) sm.description = m.description; diff --git a/packages/model-providers/src/source.ts b/packages/model-providers/src/source.ts index baffbf81c23..54a212689e8 100644 --- a/packages/model-providers/src/source.ts +++ b/packages/model-providers/src/source.ts @@ -22,7 +22,7 @@ import type { AgentKind, Catalog, Provider, ProviderPreset } from "./types.js"; /** 公共模型目录 API 路径。发布版由 model-access-server 匿名提供完整 Catalog。 */ export const CATALOG_API_PATH = - "/api/model-catalog/catalog?registrySchemaVersion=4"; + "/api/model-catalog/catalog?registrySchemaVersion=5"; /** 旧客户端目录的 OSS 相对路径。迁移期作为公共 API 失败后的兼容回退。 */ export const CATALOG_CFG_PATH = "/cfg/providers.json"; @@ -501,7 +501,8 @@ export function mergeWithBundled(primary: Catalog): Catalog { return { version: primary.version, providers: merged, - ...(presets && presets.length > 0 ? { presets } : {}), + // Keep withdrawn templates for existing connections; the add-provider endpoint filters visibility. + presets, ...(selectedRegistry.modelRegistry ? { modelRegistry: selectedRegistry.modelRegistry } : {}), diff --git a/packages/model-providers/src/types.ts b/packages/model-providers/src/types.ts index c52e790df5c..21bd18b5ed3 100644 --- a/packages/model-providers/src/types.ts +++ b/packages/model-providers/src/types.ts @@ -273,6 +273,11 @@ export interface ModelCost { * 跨 provider(如 gpt-5.5 同时由 openai 与 xd 提供)则必须元数据一致(见 catalog.ts 校验)。 */ export interface CatalogModel { + /** Published product defaults; optional on older hosts. Personal overrides remain separate. */ + defaultFast?: boolean; + preferredAgent?: AgentKind; + catalogDefaults?: { revision?: string; contextWindow?: number; effort?: Effort | null; fast?: boolean; visible?: boolean }; + userModelConfig?: ProviderRuntimeModelConfig; catalogPresetId?: string; discoveredMetadata?: ModelMetadata; @@ -567,7 +572,7 @@ export interface Provider { */ export interface ProviderRuntimeModelConfig extends Pick< ModelMetadata, - "mode" | "modalities" | "officialDocs" + "mode" | "modalities" | "officialDocs" | "description" | "maxOutputTokens" | "efforts" | "defaultEffort" | "supportsFastMode" > { discoveredMetadata?: ModelMetadata; nameExplicit?: boolean; @@ -578,6 +583,8 @@ export interface ProviderRuntimeModelConfig extends Pick< /** 同一 runtime 内该模型的上游覆盖;缺省使用 runtime 级路由。 */ route?: ProviderModelRouteConfig; contextWindow?: number; + /** Published template defaults; consumed only from a matching current preset, never a user snapshot. */ + productDefaults?: import("./modelCatalogPolicy.js").ModelProductDefaults; /** 模型未被用户显式开关时的可见性;缺省保持历史行为(默认可见)。 */ defaultEnabled?: boolean; /** Pi 自定义模型是否支持原生图片输入;缺省保守视为不支持。 */ diff --git a/packages/model-providers/src/unifiedSelection.ts b/packages/model-providers/src/unifiedSelection.ts index 4e503a9b1d4..d755b76c6ad 100644 --- a/packages/model-providers/src/unifiedSelection.ts +++ b/packages/model-providers/src/unifiedSelection.ts @@ -365,6 +365,8 @@ export function pickRecommendedAgent( const model = findCatalogModel(provider, modelId, agent, { exact: true }); if (model) models[agent] = model; } + const configured = Object.values(models).find((model) => model?.preferredAgent)?.preferredAgent; + if (configured && candidates.includes(configured)) return configured; const comparison = provider ? modelProtocolComparison(provider, models) : null; const canonical = comparison?.reference; if (canonical) { @@ -423,6 +425,7 @@ export function recommendedAgentForModel( /** 某 (provider, model, agent) 已解析的能力(规格 §2.2)。 */ export interface UnifiedAgentCapability { + defaultFast?: boolean; agent: AgentKind; /** * ★该引擎下真正要发出去的 model id(bridge 壳已还原)。写 draft / 建会话 / 切模型 @@ -501,6 +504,7 @@ function capabilityOf( ...resolveDefaultEffort(model), // 按目录里真实那条 id 查 Fast,避免归一命中后误报 false。 supportsFastMode: modelSupportsFastMode(provider, model.id, agent), + defaultFast: model.defaultFast, contextWindow: model.contextWindow, contextWindowVerified: model.contextWindowVerified === true, }; diff --git a/packages/model-providers/src/user-provider.ts b/packages/model-providers/src/user-provider.ts index b82909e4246..ece550c2652 100644 --- a/packages/model-providers/src/user-provider.ts +++ b/packages/model-providers/src/user-provider.ts @@ -1,3 +1,4 @@ +import { applyModelProductDefaults, resolveModelProductDefaults } from "./modelCatalogPolicy.js"; import { providerMediaField } from "./providerMediaModels.js"; import { expandedRegistryEntries, @@ -297,11 +298,11 @@ function toCatalogModel( ? [] : (CUSTOM_EFFORTS[agent] ?? []); const registryEfforts = - m.reasoning !== undefined || modelRegistry?.schemaVersion === 4 + m.reasoning !== undefined || [4, 5].includes(modelRegistry?.schemaVersion ?? 0) ? undefined : registryEffortMetadata(modelRegistry, m.id, agent); const supportsFastMode = - modelRegistry?.schemaVersion !== 4 && + ![4, 5].includes(modelRegistry?.schemaVersion ?? 0) && registrySupportsFastMode(modelRegistry, m.id, agent); const effectiveEfforts = registryEfforts?.efforts ?? efforts; const defaultEffort = @@ -340,7 +341,7 @@ function toCatalogModel( }; const user = runtimeUserModelMetadata(m); const resolved = - modelRegistry?.schemaVersion === 4 || + [4, 5].includes(modelRegistry?.schemaVersion ?? 0) || m.discoveredMetadata || providerDefaults ? resolveModelMetadata( @@ -419,6 +420,7 @@ function toRouting( export interface BuildUserProviderOptions { modelRegistry?: ModelRegistry | null; presets?: readonly import("./types.js").ProviderPreset[]; + catalogRevision?: string; } export function buildUserProvider( @@ -473,23 +475,31 @@ export function buildUserProvider( presetModel && sameRoute ? pickModelMetadata({ ...presetModel, - efforts: - presetModel.reasoning === false - ? [] - : presetModel.reasoningEfforts, - defaultEffort: presetModel.reasoningDefaultEffort, + ...(presetModel.reasoning !== undefined ? { + efforts: presetModel.reasoning === false ? [] : presetModel.reasoningEfforts, + defaultEffort: presetModel.reasoningDefaultEffort, + } : {}), }) : undefined; - return { - ...toCatalogModel( + let model = toCatalogModel( m, followsPreset && sameRoute ? preset!.id : config.id, agent, options.modelRegistry, defaults, - ), - ...(rt.catalogPresetId ? { catalogPresetId: rt.catalogPresetId } : {}), - }; + ); + if (followsPreset && sameRoute && presetModel) { + const policy = presetModel.productDefaults ?? resolveModelProductDefaults(options.modelRegistry ?? undefined, preset!.id, m.id, agent); + if (policy) { + const effective = { ...policy, ...policy.perAgent?.[agent] }; + // Explicit form edits are personal choices, not published defaults. + if (m.contextWindow !== undefined) delete effective.contextWindow; + if (m.reasoningDefaultEffort !== undefined) delete effective.effort; + if (m.defaultEnabled !== undefined) delete effective.visible; + model = applyModelProductDefaults(model, effective, options.catalogRevision); + } + } + return { ...model, ...(rt.catalogPresetId ? { catalogPresetId: rt.catalogPresetId } : {}) }; }); } const mediaLists: Partial< From da780098c5d6d6454445ac9fdc2b7050f5d40996 Mon Sep 17 00:00:00 2001 From: xd-bobo Date: Thu, 10 Sep 2026 15:17:41 +0800 Subject: [PATCH 2/2] feat(models): complete published catalog refresh and source diagnostics Signed-off-by: xd-bobo --- .../providerModelAutoRefresh.test.ts | 22 ++ .../src/main/maker-host/active-catalog.ts | 10 +- .../main/maker-host/catalog-consumption.ts | 36 +++ .../main/maker-host/catalog-to-descriptors.ts | 2 + .../createDesktopProviderService.ts | 10 +- .../model-plane/localCatalogOverrides.ts | 3 + .../maker-host/provider-model-auto-refresh.ts | 21 +- apps/desktop/src/main/maker-ipc/register.ts | 1 + .../modelSelectorPaymentNavigation.test.ts | 2 +- .../components/new-chat/ModelSelector.tsx | 13 +- .../components/new-chat/UnifiedModelRow.tsx | 4 +- .../settings/ModelAdvancedDrawer.tsx | 6 +- .../components/settings/ModelFieldSources.tsx | 93 +++++++ .../components/settings/UnifiedModelList.tsx | 6 +- .../__tests__/ModelAdvancedDrawer.test.tsx | 7 +- .../renderer/hooks/useAgentCapabilities.ts | 2 + .../src/renderer/i18n/locales/en/common.json | 41 ++- .../src/renderer/i18n/locales/ja/common.json | 41 ++- .../src/renderer/i18n/locales/ko/common.json | 41 ++- .../renderer/i18n/locales/zh-CN/common.json | 41 ++- .../renderer/i18n/locales/zh-TW/common.json | 41 ++- .../src/renderer/lib/modelDescriptions.ts | 63 +---- .../src/renderer/lib/modelDisplayNames.ts | 13 +- .../__tests__/mobileModelPickerLayout.test.ts | 3 +- apps/mobile/src/i18n/locales/en/models.json | 41 ++- apps/mobile/src/i18n/locales/ja/models.json | 41 ++- apps/mobile/src/i18n/locales/ko/models.json | 41 ++- .../mobile/src/i18n/locales/zh-CN/models.json | 41 ++- .../mobile/src/i18n/locales/zh-TW/models.json | 41 ++- .../src/session/MobileModelPickerList.tsx | 12 +- .../src/session/ModelOptionsSheetView.tsx | 8 + apps/mobile/src/session/modelPickerRows.ts | 2 + .../src/session/modelPickerSheetModel.ts | 3 +- docs/design-rules/design-inventory.md | 4 +- docs/dev-rules/model-catalog-maintenance.md | 11 + packages/maker-core/src/types/capabilities.ts | 3 + .../maker-shared/src/agentCapabilities.ts | 2 + .../invalid-ambiguous-model-alias.json | 105 ++++++++ .../invalid-invalid-product-default.json | 103 ++++++++ .../invalid-unknown-top-level.json | 104 ++++++++ .../invalid-unresolved-model-reference.json | 103 ++++++++ .../invalid-unresolved-preset-reference.json | 103 ++++++++ .../invalid-unsupported-locale.json | 100 ++++++++ .../invalid-v4-new-field.json | 103 ++++++++ .../fixtures/published-catalog/manifest.json | 65 +++++ .../fixtures/published-catalog/valid-v1.json | 72 ++++++ .../fixtures/published-catalog/valid-v2.json | 72 ++++++ .../fixtures/published-catalog/valid-v3.json | 72 ++++++ .../fixtures/published-catalog/valid-v4.json | 81 ++++++ .../fixtures/published-catalog/valid-v5.json | 103 ++++++++ .../scripts/exportModelPresentationSeed.ts | 69 +++++ .../src/__tests__/modelCatalogPolicy.test.ts | 236 ++++++++++++++---- .../src/__tests__/modelMetadataLayers.test.ts | 106 ++++++++ .../publishedCatalogContract.test.ts | 17 ++ packages/model-providers/src/catalog.ts | 3 + .../model-providers/src/classification.ts | 18 +- packages/model-providers/src/index.ts | 11 + .../src/legacyModelDescriptions.ts | 69 +++++ .../src/modelAccessValidator.ts | 4 +- .../model-providers/src/modelCatalogPolicy.ts | 126 ++++++++-- .../model-providers/src/modelFieldSources.ts | 38 +++ .../src/modelMetadataLayers.ts | 133 +++++++++- .../model-providers/src/modelPresentation.ts | 56 +++++ packages/model-providers/src/sections.ts | 6 + packages/model-providers/src/source.ts | 2 +- packages/model-providers/src/types.ts | 4 + .../model-providers/src/unifiedSelection.ts | 2 + packages/model-providers/src/user-provider.ts | 17 +- scripts/check-model-catalog-contract.mjs | 35 +++ 69 files changed, 2713 insertions(+), 197 deletions(-) create mode 100644 apps/desktop/src/main/maker-host/catalog-consumption.ts create mode 100644 apps/desktop/src/renderer/components/settings/ModelFieldSources.tsx create mode 100644 packages/model-providers/fixtures/published-catalog/invalid-ambiguous-model-alias.json create mode 100644 packages/model-providers/fixtures/published-catalog/invalid-invalid-product-default.json create mode 100644 packages/model-providers/fixtures/published-catalog/invalid-unknown-top-level.json create mode 100644 packages/model-providers/fixtures/published-catalog/invalid-unresolved-model-reference.json create mode 100644 packages/model-providers/fixtures/published-catalog/invalid-unresolved-preset-reference.json create mode 100644 packages/model-providers/fixtures/published-catalog/invalid-unsupported-locale.json create mode 100644 packages/model-providers/fixtures/published-catalog/invalid-v4-new-field.json create mode 100644 packages/model-providers/fixtures/published-catalog/manifest.json create mode 100644 packages/model-providers/fixtures/published-catalog/valid-v1.json create mode 100644 packages/model-providers/fixtures/published-catalog/valid-v2.json create mode 100644 packages/model-providers/fixtures/published-catalog/valid-v3.json create mode 100644 packages/model-providers/fixtures/published-catalog/valid-v4.json create mode 100644 packages/model-providers/fixtures/published-catalog/valid-v5.json create mode 100644 packages/model-providers/scripts/exportModelPresentationSeed.ts create mode 100644 packages/model-providers/src/__tests__/publishedCatalogContract.test.ts create mode 100644 packages/model-providers/src/legacyModelDescriptions.ts create mode 100644 packages/model-providers/src/modelFieldSources.ts create mode 100644 packages/model-providers/src/modelPresentation.ts create mode 100644 scripts/check-model-catalog-contract.mjs diff --git a/apps/desktop/src/main/maker-host/__tests__/providerModelAutoRefresh.test.ts b/apps/desktop/src/main/maker-host/__tests__/providerModelAutoRefresh.test.ts index 181a3ed988e..e2cb1cf2cf9 100644 --- a/apps/desktop/src/main/maker-host/__tests__/providerModelAutoRefresh.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/providerModelAutoRefresh.test.ts @@ -438,3 +438,25 @@ describe('app focus auto-refresh tracker', () => { expect(onMeaningfulForeground).not.toHaveBeenCalled(); }); }); + +it('polls only public configuration while active, at five minutes even after a recent UI hint', async () => { + vi.useFakeTimers(); + try { + const { configureProviderModelAutoRefresh, requestProviderModelAutoRefresh, PUBLIC_MODEL_CATALOG_REFRESH_MS } = await import('../provider-model-auto-refresh.js'); + let active = false; + const listProviders = vi.fn(async () => []), refreshCatalog = vi.fn(async () => undefined), refreshProvider = vi.fn(async () => undefined); + configureProviderModelAutoRefresh({listProviders,refreshCatalog,refreshProvider,isActive:() => active,log:{debug:vi.fn(),warn:vi.fn()}}); + await vi.advanceTimersByTimeAsync(PUBLIC_MODEL_CATALOG_REFRESH_MS); + expect(refreshCatalog).not.toHaveBeenCalled(); + active = true; + await vi.advanceTimersByTimeAsync(PUBLIC_MODEL_CATALOG_REFRESH_MS-1000); + await requestProviderModelAutoRefresh('model-selector-open'); + expect(refreshCatalog).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1000); + expect(refreshCatalog).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(PUBLIC_MODEL_CATALOG_REFRESH_MS); + expect(refreshCatalog).toHaveBeenCalledTimes(3); + expect(listProviders).toHaveBeenCalledTimes(1); + expect(refreshProvider).not.toHaveBeenCalled(); + } finally { vi.clearAllTimers(); vi.useRealTimers(); } +}); diff --git a/apps/desktop/src/main/maker-host/active-catalog.ts b/apps/desktop/src/main/maker-host/active-catalog.ts index 11641798b53..4c623a53f5f 100644 --- a/apps/desktop/src/main/maker-host/active-catalog.ts +++ b/apps/desktop/src/main/maker-host/active-catalog.ts @@ -6,7 +6,7 @@ import { import { applyModelProductDefaults, resolveModelProductDefaults, - resolveModelMetadata, + resolveModelMetadataWithSources as resolveModelMetadata, catalogModelMetadata, applyModelMetadata, pickModelMetadata, @@ -842,7 +842,7 @@ function assembleRoot( return { ...applyModelMetadata( model, - resolveModelMetadata(registry, providerId, model.id, metadata, undefined, agent), + resolveModelMetadata(registry, providerId, model.id, metadata, undefined, agent, undefined, upstream?.discoveredMetadata ? "discovery" : "fallback"), ), ...(metadata ? { discoveredMetadata: metadata } : {}), }; @@ -880,6 +880,8 @@ function applyLayeredConsumer( model.discoveredMetadata, undefined, agent, + undefined, + model.fieldSources ? "fallback" : "discovery", ), ) : overlaid; @@ -1597,6 +1599,8 @@ function computeMerged(): Catalog { model.discoveredMetadata ?? catalogModelMetadata(model), undefined, agent, + undefined, + model.discoveredMetadata ? "discovery" : "fallback", ), ); } @@ -1652,7 +1656,7 @@ function computeMerged(): Catalog { model.userModelConfig ? runtimeUserModelMetadata(model.userModelConfig) : {}), - }), + }, undefined, undefined, "fallback"), ); } return applyExistingModelLocalPatch( diff --git a/apps/desktop/src/main/maker-host/catalog-consumption.ts b/apps/desktop/src/main/maker-host/catalog-consumption.ts new file mode 100644 index 00000000000..86c4937f6e8 --- /dev/null +++ b/apps/desktop/src/main/maker-host/catalog-consumption.ts @@ -0,0 +1,36 @@ +import { app, net } from 'electron'; +import { createHash, randomUUID } from 'node:crypto'; +import type { Catalog } from '@cindy/model-providers'; +import { getAccessToken } from '../authManager.js'; +import { getClientEndpoint } from '../clientEndpointsService.js'; +const instanceId = randomUUID(); +/** Called only after source acceptance and successful synchronous model assembly. Best-effort, no auth side effects. */ +export function reportCatalogConsumption(catalog: Catalog): void { + if (!/^catalog-\d+$/.test(catalog.version) || catalog.modelRegistry?.schemaVersion !== 5) return; + const token = getAccessToken(); + if (!token) return; + const canonical = JSON.stringify(catalog.modelRegistry, (_key, value) => + value && typeof value === 'object' && !Array.isArray(value) + ? Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, value[key]]), + ) + : value, + ); + void net + .fetch(`${getClientEndpoint('modelAccessApiBaseUrl')}/api/model-catalog/receipts`, { + method: 'POST', + redirect: 'error', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ + instanceId, + version: catalog.version, + registryDigest: createHash('sha256').update(canonical).digest('hex'), + clientVersion: app.getVersion(), + phase: 'resolved', + }), + signal: AbortSignal.timeout(5000), + }) + .catch(() => undefined); +} diff --git a/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts b/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts index ac29784e499..90009958ae4 100644 --- a/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts +++ b/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts @@ -89,6 +89,8 @@ function toDescriptor( // 实际路由解析 —— 见下方 resolveVerifiedContextWindow(provenance 只活在 host 侧, // 不进这份跨端 descriptor)。 if (m.description !== undefined) d.description = m.description; + if (m.presentation !== undefined) d.presentation = m.presentation; + if (m.defaultFast !== undefined) d.defaultFast = m.defaultFast; if (m.effortDisplayNames !== undefined) d.effortDisplayNames = m.effortDisplayNames; if (m.supportsFastMode !== undefined) d.supportsFastMode = m.supportsFastMode; if (m.group !== undefined) d.group = m.group; diff --git a/apps/desktop/src/main/maker-host/createDesktopProviderService.ts b/apps/desktop/src/main/maker-host/createDesktopProviderService.ts index badc3e7831c..2f676a77363 100644 --- a/apps/desktop/src/main/maker-host/createDesktopProviderService.ts +++ b/apps/desktop/src/main/maker-host/createDesktopProviderService.ts @@ -1,3 +1,4 @@ +import { reportCatalogConsumption } from './catalog-consumption.js'; import { filterLegacyGptContextProfiles } from './legacy-context-profiles.js'; /** * createDesktopProviderService —— 桌面端目录加载落地 + provider-service 接线。 @@ -527,6 +528,8 @@ export function ensureActiveCatalogLoaded(): Promise { // 无 LKG / 刷新失败时 active-catalog 才继续使用 server Catalog → bundled 救急。 await loadXaiModelsFromDiskCache(); void refreshXaiModelsFromHttp(); + if (!source.url && capabilityEvidence === 'current' && authorityCatalog && getActiveCatalog().version === authorityCatalog.version) + reportCatalogConsumption(authorityCatalog); activeLoaded = true; return catalog; }) @@ -655,7 +658,10 @@ export async function refreshActiveCatalogFromSource(): Promise { capabilityEvidence, unverifiedXdMediaKinds, }); - return getActiveCatalog(); + const assembled = getActiveCatalog(); + if (!sourceConfig.url && source === 'remote' && capabilityEvidence === 'current' && authorityCatalog && assembled.version === authorityCatalog.version) + reportCatalogConsumption(authorityCatalog); + return assembled; } if (relation === 'invalid-incoming') { log.warn('model registry updatedAt is invalid; rejecting', { @@ -684,6 +690,8 @@ export async function refreshActiveCatalogFromSource(): Promise { // computeMerged 在这里同步完成,确保告警属于刚提交的同一代目录;不能读取 // 上一代惰性缓存留下的 warnings。 const activeCatalog = getActiveCatalog(); + if (!sourceConfig.url && source === 'remote' && capabilityEvidence === 'current' && authorityCatalog && activeCatalog.version === authorityCatalog.version) + reportCatalogConsumption(authorityCatalog); logModelPlaneWarnings(); broadcastReferenceModelPricing(); return activeCatalog; diff --git a/apps/desktop/src/main/maker-host/model-plane/localCatalogOverrides.ts b/apps/desktop/src/main/maker-host/model-plane/localCatalogOverrides.ts index 565ed3a33b9..47748cc70b2 100644 --- a/apps/desktop/src/main/maker-host/model-plane/localCatalogOverrides.ts +++ b/apps/desktop/src/main/maker-host/model-plane/localCatalogOverrides.ts @@ -1,3 +1,4 @@ +import { appendModelFieldSources } from "@cindy/model-providers"; import { validModelMetadata, type ModelMetadata, @@ -598,6 +599,8 @@ export function applyExistingModelLocalPatch( const patch = overrides.patches[`${encodeURIComponent(providerId)}:${model.id}`]; if (!patch || (patch.agents && !patch.agents.includes(agent))) return model; const result = overlayFields(model, { ...patch.base, ...patch.perAgent?.[agent] }); + if (typeof result !== 'string' && result.presentation && patch.base?.name !== undefined) result.presentation = Object.fromEntries(Object.entries(result.presentation).map(([locale, copy]) => [locale, { description: copy.description }])); + if (typeof result !== 'string') result.fieldSources = appendModelFieldSources(model.fieldSources, { ...patch.base, ...patch.perAgent?.[agent] }, 'user'); return typeof result === 'string' ? model : model.status === 'retired' diff --git a/apps/desktop/src/main/maker-host/provider-model-auto-refresh.ts b/apps/desktop/src/main/maker-host/provider-model-auto-refresh.ts index 84780c92efc..617ad796a38 100644 --- a/apps/desktop/src/main/maker-host/provider-model-auto-refresh.ts +++ b/apps/desktop/src/main/maker-host/provider-model-auto-refresh.ts @@ -19,6 +19,7 @@ import { } from '../../shared/providerModelRefresh.js'; import { createLogger, type Logger } from '../logger.js'; +export const PUBLIC_MODEL_CATALOG_REFRESH_MS = 5 * 60_000; export const PROVIDER_MODEL_AUTO_REFRESH_COOLDOWN_MS = 30 * 60_000; export const PROVIDER_MODEL_AUTO_REFRESH_FAILURE_COOLDOWN_MS = 5 * 60_000; export const PROVIDER_MODEL_FOREGROUND_BACKGROUND_THRESHOLD_MS = 15 * 60_000; @@ -29,6 +30,7 @@ export interface ProviderModelAutoRefreshDeps { /** Refresh the shared public Catalog independently of any provider connection state. */ refreshCatalog?: () => Promise; getScopeKey?: () => string | number; + isActive?: () => boolean; now(): number; log: Pick; } @@ -40,6 +42,7 @@ export interface ProviderModelRefreshCoordinator { ): Promise; refreshManually(providerId: BuiltinRefreshableProviderId): Promise; resetCooldowns(providerId?: BuiltinRefreshableProviderId): void; + refreshPublicCatalog(force?: boolean): Promise; } export function createProviderModelRefreshCoordinator( @@ -109,6 +112,7 @@ export function createProviderModelRefreshCoordinator( async function refreshCatalogAutomatically( trigger: ProviderModelAutoRefreshTrigger, + force = false, ): Promise { if (!deps.refreshCatalog) return; syncScope(); @@ -124,6 +128,7 @@ export function createProviderModelRefreshCoordinator( return catalogInflight.promise; } const now = deps.now(); + if (force) catalogStartupGraceUntil = undefined; if (catalogStartupGraceUntil !== undefined) { if (now < catalogStartupGraceUntil) { deps.log.debug('model catalog auto-refresh skipped by startup grace', { @@ -136,9 +141,9 @@ export function createProviderModelRefreshCoordinator( } const cooldownStartedAt = catalogLastFailureAt ?? catalogLastAttemptAt; const cooldownMs = catalogLastFailureAt === undefined - ? PROVIDER_MODEL_AUTO_REFRESH_COOLDOWN_MS + ? PUBLIC_MODEL_CATALOG_REFRESH_MS : PROVIDER_MODEL_AUTO_REFRESH_FAILURE_COOLDOWN_MS; - if (cooldownStartedAt !== undefined && now - cooldownStartedAt < cooldownMs) { + if (!force && cooldownStartedAt !== undefined && now - cooldownStartedAt < cooldownMs) { deps.log.debug('model catalog auto-refresh skipped by cooldown', { trigger, cooldown: catalogLastFailureAt === undefined ? 'normal' : 'failure-retry', @@ -260,6 +265,7 @@ export function createProviderModelRefreshCoordinator( } return { + refreshPublicCatalog: (force = false) => refreshCatalogAutomatically("foreground", force), async requestAutoRefresh(trigger, providerIds): Promise { syncScope(); const catalogRefresh = refreshCatalogAutomatically(trigger); @@ -313,9 +319,7 @@ export function createProviderModelRefreshCoordinator( }, async refreshManually(providerId): Promise { - // xAI 同时有公共静态目录与账号态媒体发现。手动刷新要把两层都刷新;自动路径 - // 已在上方统一先刷新公共目录,再调用 provider hook,因此不会重复拉 Catalog。 - if (providerId === 'xai' && deps.refreshCatalog) await deps.refreshCatalog(); + await refreshCatalogAutomatically("foreground", true); await refresh(providerId, true); }, resetCooldowns, @@ -323,17 +327,24 @@ export function createProviderModelRefreshCoordinator( } let configuredCoordinator: ProviderModelRefreshCoordinator | null = null; +let publicCatalogTimer: ReturnType | undefined; const log = createLogger('provider-model-auto-refresh'); export function configureProviderModelAutoRefresh( deps: Omit & Partial>, ): void { + if (publicCatalogTimer) clearInterval(publicCatalogTimer); configuredCoordinator = createProviderModelRefreshCoordinator({ ...deps, now: deps.now ?? Date.now, log: deps.log ?? log, }); + const coordinator = configuredCoordinator; + publicCatalogTimer = setInterval(() => { + if (deps.isActive?.() !== false) void coordinator.refreshPublicCatalog(true); + }, PUBLIC_MODEL_CATALOG_REFRESH_MS); + publicCatalogTimer.unref?.(); } export function resetProviderModelAutoRefreshCooldowns( diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 12b5a8ab1b3..b6412078ca8 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -5314,6 +5314,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) // 便于脱 Electron + 内存 db 单测。CRUD 成功后刷新 active-catalog 并广播 PROVIDER_CHANGED, // 让设置页列表 + 对话模型选择器(各 useProviders 实例)live 刷新。 configureProviderModelAutoRefresh({ + isActive: () => BrowserWindow.getAllWindows().some(window => !window.isDestroyed() && window.isFocused()) || maker.listActiveSessions().some(session => session.isTurnRunning()), listProviders: (opts) => getDesktopProviderService().listProviders(opts), getScopeKey: () => getActiveAppSession().generation, // 通知唯一出口是 active-catalog changedListener(capabilities 先对齐再广播); diff --git a/apps/desktop/src/renderer/__tests__/modelSelectorPaymentNavigation.test.ts b/apps/desktop/src/renderer/__tests__/modelSelectorPaymentNavigation.test.ts index a67c8d99694..c363ea8b994 100644 --- a/apps/desktop/src/renderer/__tests__/modelSelectorPaymentNavigation.test.ts +++ b/apps/desktop/src/renderer/__tests__/modelSelectorPaymentNavigation.test.ts @@ -32,7 +32,7 @@ describe('ModelSelector paid model navigation', () => { const modelRowBlock = modelSelectorSource.slice(start, end); expect(modelRowBlock).toContain('aria-disabled={disabled ? true : undefined}'); expect(modelRowBlock).toContain( - "`${localizedModelName(model.displayName, t)} · ${t('newChat.modelSelector.paymentRequired.unlock')}`", + "`${localizedModelName(model.displayName, t, undefined, model.presentation)} · ${t('newChat.modelSelector.paymentRequired.unlock')}`", ); expect(modelRowBlock).not.toContain('aria-disabled={disabled || paymentRequired}'); }); diff --git a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx index 83c728020ea..851068fa608 100644 --- a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx @@ -439,6 +439,7 @@ function formatContextWindow(tokens: number): string { // 单栏列表里每行 / Edit 配置列消费的最小模型形状(SectionModel 与 renderer ModelDescriptor 都满足)。 interface RowModel { + presentation?: import("@cindy/model-providers").ModelPresentation; defaultFast?: boolean; id: string; displayName: string; @@ -2136,13 +2137,13 @@ function ModelSelectorContentView({ const configPanel = editingModel ? (
{/* 名字 / 简介先帮助确认模型;面板整体居中后,操作区仍贴近当前 hover 行。 */}
- {localizedModelName(editingModel.displayName, t)} + {localizedModelName(editingModel.displayName, t, undefined, editingModel.presentation)} {editingDescription && ( @@ -2466,7 +2467,7 @@ function ModelSelectorContentView({ aria-disabled={disabled ? true : undefined} aria-label={ paymentRequired - ? `${localizedModelName(model.displayName, t)} · ${t('newChat.modelSelector.paymentRequired.unlock')}` + ? `${localizedModelName(model.displayName, t, undefined, model.presentation)} · ${t('newChat.modelSelector.paymentRequired.unlock')}` : undefined } title={disabledReason ?? undefined} @@ -2536,7 +2537,7 @@ function ModelSelectorContentView({ - {localizedModelName(model.displayName, t)} + {localizedModelName(model.displayName, t, undefined, model.presentation)} {rowEffort && ( {/* 断开来源可能是该模型的唯一提供方 → visibleModels 查不到,回落显示原始 id, 比 "Select model" 占位更能说明「哪个模型的来源断了」。 */} - {currentModel ? localizedModelName(currentModel.displayName, t) : modelId} + {currentModel ? localizedModelName(currentModel.displayName, t, undefined, currentModel.presentation) : modelId} {/* 来源断开是**来源**的事,引擎身份位照常保留(规格 §1.2:引擎可见性靠一致的 结构位,不靠出错才显示)。 */} diff --git a/apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx b/apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx index 82afc7b289f..fef40692397 100644 --- a/apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx +++ b/apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx @@ -75,8 +75,8 @@ export function UnifiedModelRow({ onPaymentRequired?: () => void; }) { const { t } = useTranslation(); - const displayName = localizedModelName(entry.displayName, t); - const description = localizedModelDescription({ id: entry.modelId, group: entry.group }, t); + const displayName = localizedModelName(entry.displayName, t, undefined, entry.presentation); + const description = localizedModelDescription({ id: entry.modelId, group: entry.group, presentation: entry.presentation }, t); const provider = providers.find((item) => item.id === entry.providerId); const priceSymbol = priceDisplay?.symbol ?? '$'; const engineOption = agentOptionOf(config.engine); diff --git a/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx b/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx index ffa6480d9ee..7cac3a79328 100644 --- a/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx +++ b/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx @@ -1,3 +1,4 @@ +import { ModelFieldSources } from './ModelFieldSources'; import { localizedModelDescription } from '@/lib/modelDescriptions'; import { localizedModelName, localizedBrandName } from '@/lib/modelDisplayNames'; /** @@ -418,7 +419,7 @@ export function ModelAdvancedDrawer({ tabIndex={-1} className="break-words text-15 font-medium text-[var(--text-primary)] outline-none" > - {localizedModelName(primaryModel.name, t)} + {localizedModelName(primaryModel.name, t, undefined, primaryModel.presentation)}

{provider.id === 'xd' ? t('settings.providers.xd.title') : provider.name} @@ -442,6 +443,7 @@ export function ModelAdvancedDrawer({ {' · '}{t(ctx.limit !== null ? 'settings.providers.models.advanced.personalWindow' : 'settings.providers.models.advanced.followCatalog')}

)} +
{conversational && ( @@ -566,7 +568,7 @@ export function ModelAdvancedDrawer({ ); } }} - aria-label={`${localizedModelName(primaryModel.name, t)} · ${AGENT_LABEL[agent]}`} + aria-label={`${localizedModelName(primaryModel.name, t, undefined, primaryModel.presentation)} · ${AGENT_LABEL[agent]}`} />
diff --git a/apps/desktop/src/renderer/components/settings/ModelFieldSources.tsx b/apps/desktop/src/renderer/components/settings/ModelFieldSources.tsx new file mode 100644 index 00000000000..ea70e8de3fb --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/ModelFieldSources.tsx @@ -0,0 +1,93 @@ +import { useTranslation } from 'react-i18next'; +import { modelNewRouteBlockReason, type CatalogModel } from '@cindy/model-providers'; +export function ModelFieldSources({ + model, + contextLimit, + visible, + connected, + suspended, + userProvider, +}: { + model: CatalogModel; + contextLimit: number | null; + visible: boolean; + connected: boolean; + suspended: boolean; + userProvider: boolean; +}) { + const { t } = useTranslation(); + const reason = !connected + ? 'disconnected' + : suspended + ? 'suspended' + : modelNewRouteBlockReason(model, { userProvider }); + const sources = { ...model.fieldSources }; + if (contextLimit !== null) + sources.contextWindow = [ + ...(sources.contextWindow ?? []), + { source: 'user', value: contextLimit }, + ]; + const fields = [ + 'name', + 'presentation', + 'mode', + 'modalities', + 'group', + 'officialDocs', + 'description', + 'contextWindow', + 'maxOutputTokens', + 'efforts', + 'defaultEffort', + 'supportsFastMode', + 'supportsImageInput', + 'defaultFast', + 'defaultEnabled', + 'preferredAgent', + ]; + const label = (field: string) => + t(`settings.providers.models.advanced.provenance.fields.${field}`); + const display = (value: unknown) => + value === null ? '—' : typeof value === 'object' ? JSON.stringify(value) : String(value); + return ( +
+ + {t('settings.providers.models.advanced.provenance.title')} + +

{t('settings.providers.models.advanced.provenance.hint')}

+ {reason && ( +

+ {t(`settings.providers.models.advanced.provenance.reasons.${reason}`)} +

+ )} + {!visible && ( +

{t('settings.providers.models.advanced.provenance.reasons.hidden')}

+ )} + {!Object.keys(sources).length && ( +

{t('settings.providers.models.advanced.provenance.noTrace')}

+ )} +
+ {fields + .filter((field) => sources[field]?.length) + .map((field) => ( +
+
{label(field)}
+
+ {sources[field].map((record, index) => ( + + {index ? ' → ' : ''} + {t( + `settings.providers.models.advanced.provenance.sources.${record.source}`, + )}: {display(record.value)} + {record.reason + ? ` (${record.reason}${record.verifiedAt ? ` · ${record.verifiedAt}` : ''})` + : ''} + + ))} +
+
+ ))} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx b/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx index 59952a96da9..b9ed0bb0537 100644 --- a/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx +++ b/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx @@ -887,7 +887,7 @@ export function UnifiedModelList({ color: state.ready && (capability || anyOn) ? 'var(--settings-section-title)' : 'var(--text-tertiary)', }} > - {localizedModelName(rep.name, t)} + {localizedModelName(rep.name, t, undefined, rep.presentation)} {/* 价格档与折扣紧跟模型名(不单独成列):这一列不是用来纵向比价的, 是用来在读到某个模型名时顺手知道它贵不贵。拿不到报价就整个 @@ -955,7 +955,7 @@ export function UnifiedModelList({ disabled={!state.canSelect} checked={anyOn} onCheckedChange={() => toggleRow(row)} - aria-label={localizedModelName(rep.name, t)} + aria-label={localizedModelName(rep.name, t, undefined, rep.presentation)} /> )}
@@ -1309,7 +1309,7 @@ export function UnifiedModelList({ className="min-w-0 truncate text-14 font-medium" style={{ color: 'var(--text-disabled)' }} > - {localizedModelName(rep.name, t)} + {localizedModelName(rep.name, t, undefined, rep.presentation)}
{/* 来源分组注记:启用后会回到哪个组,别让用户猜。 */} { it('shows facts beside controls without disclosure and keeps related window values together', () => { draw(); const dialog = screen.getByRole('dialog'); - expect(dialog.querySelector('details')).toBeNull(); + // Optional source diagnostics may collapse; the facts and editable values remain visible. + for (const key of ['imageInput', 'modelId', 'contextWindow']) { + expect(screen.getByText(`settings.providers.models.advanced.${key}`).closest('details')).toBeNull(); + } + const sources = dialog.querySelector('details'); + expect(sources?.querySelector('summary')?.textContent).toBe('settings.providers.models.advanced.provenance.title'); expect(document.activeElement).toBe(screen.getByRole('heading', { name: 'GPT-6' })); const input = screen.getByRole('textbox'); const controlsColumn = input.closest('section')!.parentElement!; diff --git a/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts b/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts index f0850dae9d2..ee980ac9466 100644 --- a/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts +++ b/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts @@ -27,6 +27,8 @@ const ALL_AGENT_KINDS = ['claude-code', 'codex', 'pi'] as const; // renderer 视角: id 全部是不透明 string, 渲染只读 displayName。 // effort 的合法 id 集合 = capabilities.effortLevels 上每个项的 id。 export interface ModelDescriptor { + presentation?: import("@cindy/model-providers").ModelPresentation; + defaultFast?: boolean; id: string; displayName: string; /** 目录分组 id(如 'gpt-budget'): 折扣版与官方版 displayName 同名, 靠它区分。 */ diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index c55961f6b6e..74a0bcbdc3b 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -1938,7 +1938,46 @@ "contextLimitGptHint": "GPT defaults to at most 272K; you can set it to 1M here. Codex applies the window and automatic compaction threshold together, retaining 5% headroom. Actual capacity depends on the model service.", "catalogRevision": "Published configuration: {{revision}}", "personalWindow": "Using your context limit", - "followCatalog": "Following published defaults" + "followCatalog": "Following published defaults", + "provenance": { + "title": "Field sources", + "hint": "Read left to right; later values override earlier values. These are the defaults for this model. Saved task choices remain unchanged.", + "noTrace": "This host has no field source details.", + "fields": { + "name": "Name", + "description": "Description", + "contextWindow": "Context window", + "maxOutputTokens": "Maximum output", + "efforts": "Reasoning levels", + "defaultEffort": "Default reasoning", + "supportsFastMode": "Fast support", + "supportsImageInput": "Image input", + "defaultFast": "Default Fast", + "defaultEnabled": "Default visibility", + "preferredAgent": "Preferred engine", "mode": "Model type", "modalities": "Input / output", "group": "Group", "officialDocs": "Official documentation", "presentation": "Published name and description" + }, + "sources": { + "public": "Shared model", + "provider": "Provider defaults", + "access": "Access defaults", + "engine": "Engine", + "discovery": "Provider response", + "verified": "Verified correction", + "user": "Personal setting", + "product": "Published defaults", + "fallback": "Installed fallback", + "constraint": "Capability limit" + }, + "reasons": { + "disconnected": "Provider is disconnected.", + "suspended": "Provider is disabled.", + "disabled": "Model is disabled for new requests.", + "retired": "Model is retired; new requests are not allowed.", + "requires_payment": "Access requires a subscription or payment.", + "non_chat": "This model does not support agent conversations.", + "hidden": "Hidden from the list; hiding does not disable explicit invocation." + } + } }, "kindFilter": { "all": "All", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 50d1d8b2bfa..164dc53d3f7 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -1937,7 +1937,46 @@ "contextLimitGptHint": "GPT は既定で最大 272K を使用します。ここで 1M に変更できます。Codex はウィンドウと自動圧縮のしきい値を同時に適用し、5% の余裕を確保します。実際の容量はモデルサービスに依存します。", "catalogRevision": "公開済み設定:{{revision}}", "personalWindow": "設定したコンテキスト上限を使用中", - "followCatalog": "公開済みの既定値を使用中" + "followCatalog": "公開済みの既定値を使用中", + "provenance": { + "title": "項目の情報源", + "hint": "左から右へ、後の値が前の値を上書きします。モデルの既定設定を表示します。既存タスクに保存された選択は維持されます。", + "noTrace": "このホストは項目の情報源を提供していません。", + "fields": { + "name": "名前", + "description": "説明", + "contextWindow": "コンテキスト枠", + "maxOutputTokens": "最大出力", + "efforts": "推論強度", + "defaultEffort": "既定の推論", + "supportsFastMode": "Fast 対応", + "supportsImageInput": "画像入力", + "defaultFast": "既定の Fast", + "defaultEnabled": "既定の表示", + "preferredAgent": "推奨エンジン", "mode": "モデルの種類", "modalities": "入力 / 出力", "group": "グループ", "officialDocs": "公式ドキュメント", "presentation": "公開された名前と説明" + }, + "sources": { + "public": "共通モデル", + "provider": "プロバイダー既定", + "access": "接続既定", + "engine": "エンジン", + "discovery": "プロバイダー応答", + "verified": "検証済み修正", + "user": "個人設定", + "product": "公開済み既定", + "fallback": "内蔵の代替", + "constraint": "機能の制限" + }, + "reasons": { + "disconnected": "プロバイダーは未接続です。", + "suspended": "プロバイダーは無効です。", + "disabled": "モデルは新しいリクエストに使用できません。", + "retired": "提供終了のため新しいリクエストには使用できません。", + "requires_payment": "アクセスにはサブスクリプションまたは支払いが必要です。", + "non_chat": "このモデルはエージェントの会話に対応していません。", + "hidden": "一覧から非表示です。明示的な呼び出しは無効化されません。" + } + } }, "kindFilter": { "all": "すべて", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index b006d79aaff..6257e8fc6f7 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -1937,7 +1937,46 @@ "contextLimitGptHint": "GPT는 기본적으로 최대 272K를 사용하며 여기서 1M으로 변경할 수 있습니다. Codex는 창과 자동 압축 임계값을 함께 적용하고 5%의 여유를 유지합니다. 실제 용량은 모델 서비스에 따라 다릅니다.", "catalogRevision": "게시된 설정: {{revision}}", "personalWindow": "사용자가 설정한 컨텍스트 제한 사용 중", - "followCatalog": "게시된 기본값 사용 중" + "followCatalog": "게시된 기본값 사용 중", + "provenance": { + "title": "필드 출처", + "hint": "왼쪽에서 오른쪽으로, 뒤의 값이 앞의 값을 덮어씁니다. 모델 기본 설정이며 기존 작업에 저장한 선택은 유지됩니다.", + "noTrace": "이 호스트는 필드 출처를 제공하지 않습니다.", + "fields": { + "name": "이름", + "description": "설명", + "contextWindow": "컨텍스트 창", + "maxOutputTokens": "최대 출력", + "efforts": "추론 단계", + "defaultEffort": "기본 추론", + "supportsFastMode": "Fast 지원", + "supportsImageInput": "이미지 입력", + "defaultFast": "기본 Fast", + "defaultEnabled": "기본 표시", + "preferredAgent": "권장 엔진", "mode": "모델 유형", "modalities": "입력 / 출력", "group": "그룹", "officialDocs": "공식 문서", "presentation": "게시된 이름 및 설명" + }, + "sources": { + "public": "공통 모델", + "provider": "제공자 기본값", + "access": "연결 기본값", + "engine": "엔진", + "discovery": "제공자 응답", + "verified": "검증된 수정", + "user": "개인 설정", + "product": "게시된 기본값", + "fallback": "내장 대체값", + "constraint": "기능 제한" + }, + "reasons": { + "disconnected": "제공자가 연결되어 있지 않습니다.", + "suspended": "제공자가 비활성화되어 있습니다.", + "disabled": "모델이 비활성화되어 새 요청에 사용할 수 없습니다.", + "retired": "제공이 종료되어 새 요청에 사용할 수 없습니다.", + "requires_payment": "접근하려면 구독 또는 결제가 필요합니다.", + "non_chat": "이 모델은 에이전트 대화를 지원하지 않습니다.", + "hidden": "목록에서 숨겨졌습니다. 명시적 호출은 비활성화되지 않습니다." + } + } }, "kindFilter": { "all": "전체", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index ba760de0a2d..6b571499856 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -1937,7 +1937,46 @@ "contextLimitGptHint": "GPT 默认最多使用 272K;可在此自定义到 1M。Codex 会同步应用窗口与自动压缩阈值,保留 5% 余量;实际容量受模型服务限制。", "catalogRevision": "发布配置:{{revision}}", "personalWindow": "正在使用你的上下文限制", - "followCatalog": "正在跟随发布默认值" + "followCatalog": "正在跟随发布默认值", + "provenance": { + "title": "字段来源", + "hint": "从左到右,后面的值覆盖前面的值。这里展示该模型的默认配置;已有任务中保存的选择保持不变。", + "noTrace": "当前主机未提供字段来源明细。", + "fields": { + "name": "名称", + "description": "简介", + "contextWindow": "使用窗口", + "maxOutputTokens": "最大输出", + "efforts": "思考档位", + "defaultEffort": "默认思考档位", + "supportsFastMode": "Fast 能力", + "supportsImageInput": "图片输入", + "defaultFast": "默认 Fast", + "defaultEnabled": "默认显示", + "preferredAgent": "默认引擎", "mode": "模型类型", "modalities": "输入 / 输出", "group": "分组", "officialDocs": "官方文档", "presentation": "发布的名称与简介" + }, + "sources": { + "public": "共用型号", + "provider": "供应商默认", + "access": "接入默认", + "engine": "引擎差异", + "discovery": "供应商实报", + "verified": "已核实修正", + "user": "个人设置", + "product": "已发布默认", + "fallback": "内置兜底", + "constraint": "能力限制" + }, + "reasons": { + "disconnected": "供应商尚未连接。", + "suspended": "供应商已停用。", + "disabled": "模型已停用,不能用于新请求。", + "retired": "模型已停止使用,不能发起新请求。", + "requires_payment": "当前账号尚无访问权限,需要订阅或付费。", + "non_chat": "此模型不支持 Agent 对话。", + "hidden": "已从清单隐藏;隐藏不会禁止明确指定调用。" + } + } }, "kindFilter": { "all": "全部", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index e5079802acb..9a46cee23e7 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -1937,7 +1937,46 @@ "contextLimitGptHint": "GPT 預設最多使用 272K;可在此自訂為 1M。Codex 會同步套用視窗與自動壓縮門檻,保留 5% 餘量;實際容量受模型服務限制。", "catalogRevision": "發布設定:{{revision}}", "personalWindow": "正在使用你的上下文限制", - "followCatalog": "正在跟隨發布預設值" + "followCatalog": "正在跟隨發布預設值", + "provenance": { + "title": "欄位來源", + "hint": "由左至右,後面的值覆蓋前面的值。這裡顯示此模型的預設設定;現有任務儲存的選擇保持不變。", + "noTrace": "目前主機未提供欄位來源明細。", + "fields": { + "name": "名稱", + "description": "簡介", + "contextWindow": "使用視窗", + "maxOutputTokens": "最大輸出", + "efforts": "思考檔位", + "defaultEffort": "預設思考檔位", + "supportsFastMode": "Fast 能力", + "supportsImageInput": "圖片輸入", + "defaultFast": "預設 Fast", + "defaultEnabled": "預設顯示", + "preferredAgent": "預設引擎", "mode": "模型類型", "modalities": "輸入 / 輸出", "group": "分組", "officialDocs": "官方文件", "presentation": "發布的名稱與簡介" + }, + "sources": { + "public": "共用型號", + "provider": "供應商預設", + "access": "接入預設", + "engine": "引擎差異", + "discovery": "供應商回報", + "verified": "已核實修正", + "user": "個人設定", + "product": "已發布預設", + "fallback": "內建備用", + "constraint": "能力限制" + }, + "reasons": { + "disconnected": "供應商尚未連線。", + "suspended": "供應商已停用。", + "disabled": "模型已停用,無法用於新請求。", + "retired": "模型已停止使用,無法發起新請求。", + "requires_payment": "目前帳號尚無存取權限,需要訂閱或付費。", + "non_chat": "此模型不支援 Agent 對話。", + "hidden": "已從清單隱藏;隱藏不會禁止明確指定呼叫。" + } + } }, "kindFilter": { "all": "全部", diff --git a/apps/desktop/src/renderer/lib/modelDescriptions.ts b/apps/desktop/src/renderer/lib/modelDescriptions.ts index a89146cc87f..bf802f82e7e 100644 --- a/apps/desktop/src/renderer/lib/modelDescriptions.ts +++ b/apps/desktop/src/renderer/lib/modelDescriptions.ts @@ -1,65 +1,14 @@ -import { classifyModel } from '@cindy/model-providers'; -import type { TFunction } from 'i18next'; +import { modelDescriptionKey, localizedModelPresentation, type ModelPresentation } from '@cindy/model-providers'; +import i18next, { type TFunction } from 'i18next'; -/** Editorial display copy only: never use a description to infer routing or capabilities. - * Families intentionally accept future versions; new families remain visible without a blurb. - * Raw catalog descriptions are retained in the catalog, but never used as UI fallback. - */ -const families: ReadonlyArray = [ - [/^gpt-.*\[1m\]$/, 'longContext'], - [/^gpt-.*-cyber(?:-|$)/, 'security'], - [/^gpt-.*-astra(?:-|$)/, 'research'], - [/^gpt-.*(?:-luna|-mini|-nano|-spark)(?:-|$)/, 'quickCoding'], - [/^(?:gpt-.*-pro(?:-|$)|o[134](?:-|$))/, 'reasoning'], - [/^gpt-/, 'coding'], - [/^claude-fable-/, 'sustainedWork'], - [/^claude-opus-/, 'complexWork'], - [/^claude-sonnet-/, 'everydayWork'], - [/^claude-haiku-/, 'quickAnswers'], - [/^claude-/, 'everydayWork'], - [/^gemini-.*flash-lite/, 'shortTasks'], - [/^gemini-.*flash/, 'everydayWork'], - [/^gemini-/, 'research'], - [/^deepseek-.*(?:-vision|-vl)/, 'everydayWork'], - [/^deepseek-.*-flash(?:-|$)/, 'everydayWork'], - [/^deepseek-/, 'reasoning'], - [/^qwen(?:\d|[-/])/, 'writingAndCoding'], - [/^kimi-.*-code(?:-|$)/, 'coding'], - [/^kimi-/, 'readingAndWriting'], - [/^glm-/, 'writingAndCoding'], - [/^(?:doubao-)?seed-\d/, 'everydayWork'], - [/^(?:hy\d|hunyuan(?:-|\d))/, 'readingAndWriting'], - [/^minimax-/, 'coding'], - [/^muse-spark-/, 'everydayWork'], - [/^grok-(?:build|code)-/, 'coding'], - [/^grok-/, 'reasoning'], -]; - -const categoryKeys: Readonly> = { - image: 'image', video: 'video', tts: 'speech', stt: 'transcription', - realtime: 'realtime', embedding: 'embedding', compression: 'compression', -}; - -export function modelDescriptionKey(model: { id: string; group?: string; mode?: string }): string | undefined { - // A vendor grouping like "gpt" must not turn GPT Image into a coding model. - const inferredCategory = classifyModel({ id: model.id, mode: model.mode }); - const category = model.mode !== undefined || Object.hasOwn(categoryKeys, inferredCategory) - ? inferredCategory : classifyModel(model); - if (Object.hasOwn(categoryKeys, category)) return categoryKeys[category]; - // An explicitly non-chat/unknown mode must not inherit a chat-family blurb. - if (category === 'other' || category === 'audio') return undefined; - let id = model.id.toLowerCase().split('/').at(-1) ?? ''; - // Registry internal XD IDs have flattened route prefixes. Wire IDs usually don't. - if (model.id.startsWith('xd/')) { - id = id.replace(/^(?:codex|z-ai|moonshotai|deepseek|tencent)-(?=gpt-|glm-|kimi-|deepseek-|hy\d)/, ''); - } - return families.find(([pattern]) => pattern.test(id))?.[1]; -} +export { modelDescriptionKey } from "@cindy/model-providers"; export function localizedModelDescription( - model: { id: string; group?: string; mode?: string }, + model: { id: string; group?: string; mode?: string; presentation?: ModelPresentation }, t: TFunction, ): string | undefined { + const copy = localizedModelPresentation(model.presentation, i18next.resolvedLanguage ?? i18next.language ?? "en"); + if (copy?.description) return copy.description; const key = modelDescriptionKey(model); if (!key) return undefined; return t(`modelDescriptions.${key}`, { defaultValue: '' }) || undefined; diff --git a/apps/desktop/src/renderer/lib/modelDisplayNames.ts b/apps/desktop/src/renderer/lib/modelDisplayNames.ts index 316c10b642a..2c946d0c79a 100644 --- a/apps/desktop/src/renderer/lib/modelDisplayNames.ts +++ b/apps/desktop/src/renderer/lib/modelDisplayNames.ts @@ -1,3 +1,5 @@ +import { localizedModelPresentation, type ModelPresentation } from '@cindy/model-providers'; +import i18next from 'i18next'; import { localizedModelDescription } from './modelDescriptions'; import type { CatalogModel } from '@cindy/model-providers'; import type { ProviderLogoKind } from '@cindy/model-providers/branding'; @@ -71,7 +73,9 @@ const familyNames: Array<[RegExp, string, string]> = [ [/^(?:Hunyuan|Hy)(?=[\d\s-]|$)/i, 'hunyuan', 'Hunyuan'], ]; -export function localizedModelName(name: string, t: TFunction, locale?: string): string { +export function localizedModelName(name: string, t: TFunction, locale?: string, presentation?: ModelPresentation): string { + const published = localizedModelPresentation(presentation, locale ?? i18next.resolvedLanguage ?? i18next.language ?? 'en'); + if (published?.name) return published.name; for (const [pattern, key, canonical] of familyNames) { const prefix = pattern.exec(name)?.[0]; if (!prefix) continue; @@ -90,16 +94,17 @@ export function localizedBrandName(brand: ModelBrand, t: TFunction): string { /** Both localized display labels and original provider names/IDs remain searchable. */ export function matchesModelName( - model: { id: string; name?: string; displayName?: string; description?: string; group?: string; mode?: string }, + model: { id: string; name?: string; displayName?: string; description?: string; group?: string; mode?: string; presentation?: ModelPresentation }, query: string, t: TFunction, ): boolean { const name = model.name ?? model.displayName ?? model.id; const brand = modelBrand(model); const haystack = [model.id, name, model.description, brand?.label, - localizedModelName(name, t), localizedModelDescription(model, t), + localizedModelName(name, t, undefined, model.presentation), localizedModelDescription(model, t), + ...Object.values(model.presentation ?? {}).flatMap(copy => [copy.name, copy.description]), ...['zh-CN', 'zh-TW', 'en'].flatMap((lng) => [ - localizedModelName(name, t, lng), + localizedModelName(name, t, lng, model.presentation), brand && Object.hasOwn(brands, brand.key) ? t(`modelNames.manufacturers.${brand.key}`, { lng, defaultValue: brand.label }) : '', ]), diff --git a/apps/mobile/src/__tests__/mobileModelPickerLayout.test.ts b/apps/mobile/src/__tests__/mobileModelPickerLayout.test.ts index 0ffae83e428..880e7e6345d 100644 --- a/apps/mobile/src/__tests__/mobileModelPickerLayout.test.ts +++ b/apps/mobile/src/__tests__/mobileModelPickerLayout.test.ts @@ -15,7 +15,8 @@ const source = readFileSync( describe('MobileModelPickerList compact rows', () => { it('gives provider-aware model names a dedicated primary line', () => { - const modelName = source.indexOf('{row.model.displayName}'); + const modelName = source.indexOf('{displayName}'); + expect(source).toContain('const displayName = presentation?.name ?? row.model.displayName;'); const metadata = source.indexOf( '{isSubscription || rowEffort || fastOn ? (', modelName, diff --git a/apps/mobile/src/i18n/locales/en/models.json b/apps/mobile/src/i18n/locales/en/models.json index 6ecd6c3403e..33b856ee1df 100644 --- a/apps/mobile/src/i18n/locales/en/models.json +++ b/apps/mobile/src/i18n/locales/en/models.json @@ -22,7 +22,46 @@ "priceAmounts": "Input {{input}} · Output {{output}}", "discountedVsStandard": "Discounted · {{percent}}% off standard", "contextSuffix": "{{size}} context", - "fastTag": "Fast" + "fastTag": "Fast", + "provenance": { + "title": "Field sources", + "hint": "Read left to right; later values override earlier values. These are the defaults for this model. Saved task choices remain unchanged.", + "noTrace": "This host has no field source details.", + "fields": { + "name": "Name", + "description": "Description", + "contextWindow": "Context window", + "maxOutputTokens": "Maximum output", + "efforts": "Reasoning levels", + "defaultEffort": "Default reasoning", + "supportsFastMode": "Fast support", + "supportsImageInput": "Image input", + "defaultFast": "Default Fast", + "defaultEnabled": "Default visibility", + "preferredAgent": "Preferred engine", "mode": "Model type", "modalities": "Input / output", "group": "Group", "officialDocs": "Official documentation", "presentation": "Published name and description" + }, + "sources": { + "public": "Shared model", + "provider": "Provider defaults", + "access": "Access defaults", + "engine": "Engine", + "discovery": "Provider response", + "verified": "Verified correction", + "user": "Personal setting", + "product": "Published defaults", + "fallback": "Installed fallback", + "constraint": "Capability limit" + }, + "reasons": { + "disconnected": "Provider is disconnected.", + "suspended": "Provider is disabled.", + "disabled": "Model is disabled for new requests.", + "retired": "Model is retired; new requests are not allowed.", + "requires_payment": "Access requires a subscription or payment.", + "non_chat": "This model does not support agent conversations.", + "hidden": "Hidden from the list; hiding does not disable explicit invocation." + } + } }, "options": { "fastMode": "Fast Mode", diff --git a/apps/mobile/src/i18n/locales/ja/models.json b/apps/mobile/src/i18n/locales/ja/models.json index 09dcdc66887..13fefe4a47c 100644 --- a/apps/mobile/src/i18n/locales/ja/models.json +++ b/apps/mobile/src/i18n/locales/ja/models.json @@ -22,7 +22,46 @@ "priceAmounts": "入力 {{input}} · 出力 {{output}}", "discountedVsStandard": "割引中 · 標準価格から {{percent}}% お得", "contextSuffix": "{{size}} コンテキスト", - "fastTag": "高速" + "fastTag": "高速", + "provenance": { + "title": "項目の情報源", + "hint": "左から右へ、後の値が前の値を上書きします。モデルの既定設定を表示します。既存タスクに保存された選択は維持されます。", + "noTrace": "このホストは項目の情報源を提供していません。", + "fields": { + "name": "名前", + "description": "説明", + "contextWindow": "コンテキスト枠", + "maxOutputTokens": "最大出力", + "efforts": "推論強度", + "defaultEffort": "既定の推論", + "supportsFastMode": "Fast 対応", + "supportsImageInput": "画像入力", + "defaultFast": "既定の Fast", + "defaultEnabled": "既定の表示", + "preferredAgent": "推奨エンジン", "mode": "モデルの種類", "modalities": "入力 / 出力", "group": "グループ", "officialDocs": "公式ドキュメント", "presentation": "公開された名前と説明" + }, + "sources": { + "public": "共通モデル", + "provider": "プロバイダー既定", + "access": "接続既定", + "engine": "エンジン", + "discovery": "プロバイダー応答", + "verified": "検証済み修正", + "user": "個人設定", + "product": "公開済み既定", + "fallback": "内蔵の代替", + "constraint": "機能の制限" + }, + "reasons": { + "disconnected": "プロバイダーは未接続です。", + "suspended": "プロバイダーは無効です。", + "disabled": "モデルは新しいリクエストに使用できません。", + "retired": "提供終了のため新しいリクエストには使用できません。", + "requires_payment": "アクセスにはサブスクリプションまたは支払いが必要です。", + "non_chat": "このモデルはエージェントの会話に対応していません。", + "hidden": "一覧から非表示です。明示的な呼び出しは無効化されません。" + } + } }, "options": { "fastMode": "高速モード", diff --git a/apps/mobile/src/i18n/locales/ko/models.json b/apps/mobile/src/i18n/locales/ko/models.json index 4a1773e89eb..0b2f0c815b2 100644 --- a/apps/mobile/src/i18n/locales/ko/models.json +++ b/apps/mobile/src/i18n/locales/ko/models.json @@ -22,7 +22,46 @@ "priceAmounts": "입력 {{input}} · 출력 {{output}}", "discountedVsStandard": "할인 중 · 표준가 대비 {{percent}}% 절약", "contextSuffix": "{{size}} 컨텍스트", - "fastTag": "빠름" + "fastTag": "빠름", + "provenance": { + "title": "필드 출처", + "hint": "왼쪽에서 오른쪽으로, 뒤의 값이 앞의 값을 덮어씁니다. 모델 기본 설정이며 기존 작업에 저장한 선택은 유지됩니다.", + "noTrace": "이 호스트는 필드 출처를 제공하지 않습니다.", + "fields": { + "name": "이름", + "description": "설명", + "contextWindow": "컨텍스트 창", + "maxOutputTokens": "최대 출력", + "efforts": "추론 단계", + "defaultEffort": "기본 추론", + "supportsFastMode": "Fast 지원", + "supportsImageInput": "이미지 입력", + "defaultFast": "기본 Fast", + "defaultEnabled": "기본 표시", + "preferredAgent": "권장 엔진", "mode": "모델 유형", "modalities": "입력 / 출력", "group": "그룹", "officialDocs": "공식 문서", "presentation": "게시된 이름 및 설명" + }, + "sources": { + "public": "공통 모델", + "provider": "제공자 기본값", + "access": "연결 기본값", + "engine": "엔진", + "discovery": "제공자 응답", + "verified": "검증된 수정", + "user": "개인 설정", + "product": "게시된 기본값", + "fallback": "내장 대체값", + "constraint": "기능 제한" + }, + "reasons": { + "disconnected": "제공자가 연결되어 있지 않습니다.", + "suspended": "제공자가 비활성화되어 있습니다.", + "disabled": "모델이 비활성화되어 새 요청에 사용할 수 없습니다.", + "retired": "제공이 종료되어 새 요청에 사용할 수 없습니다.", + "requires_payment": "접근하려면 구독 또는 결제가 필요합니다.", + "non_chat": "이 모델은 에이전트 대화를 지원하지 않습니다.", + "hidden": "목록에서 숨겨졌습니다. 명시적 호출은 비활성화되지 않습니다." + } + } }, "options": { "fastMode": "빠른 모드", diff --git a/apps/mobile/src/i18n/locales/zh-CN/models.json b/apps/mobile/src/i18n/locales/zh-CN/models.json index f45c4c3ffb7..c0f8c16ed01 100644 --- a/apps/mobile/src/i18n/locales/zh-CN/models.json +++ b/apps/mobile/src/i18n/locales/zh-CN/models.json @@ -22,7 +22,46 @@ "priceAmounts": "输入 {{input}} · 输出 {{output}}", "discountedVsStandard": "折扣中,较标准价省 {{percent}}%", "contextSuffix": "{{size}} 上下文", - "fastTag": "快速" + "fastTag": "快速", + "provenance": { + "title": "字段来源", + "hint": "从左到右,后面的值覆盖前面的值。这里展示该模型的默认配置;已有任务中保存的选择保持不变。", + "noTrace": "当前主机未提供字段来源明细。", + "fields": { + "name": "名称", + "description": "简介", + "contextWindow": "使用窗口", + "maxOutputTokens": "最大输出", + "efforts": "思考档位", + "defaultEffort": "默认思考档位", + "supportsFastMode": "Fast 能力", + "supportsImageInput": "图片输入", + "defaultFast": "默认 Fast", + "defaultEnabled": "默认显示", + "preferredAgent": "默认引擎", "mode": "模型类型", "modalities": "输入 / 输出", "group": "分组", "officialDocs": "官方文档", "presentation": "发布的名称与简介" + }, + "sources": { + "public": "共用型号", + "provider": "供应商默认", + "access": "接入默认", + "engine": "引擎差异", + "discovery": "供应商实报", + "verified": "已核实修正", + "user": "个人设置", + "product": "已发布默认", + "fallback": "内置兜底", + "constraint": "能力限制" + }, + "reasons": { + "disconnected": "供应商尚未连接。", + "suspended": "供应商已停用。", + "disabled": "模型已停用,不能用于新请求。", + "retired": "模型已停止使用,不能发起新请求。", + "requires_payment": "当前账号尚无访问权限,需要订阅或付费。", + "non_chat": "此模型不支持 Agent 对话。", + "hidden": "已从清单隐藏;隐藏不会禁止明确指定调用。" + } + } }, "options": { "fastMode": "快速模式", diff --git a/apps/mobile/src/i18n/locales/zh-TW/models.json b/apps/mobile/src/i18n/locales/zh-TW/models.json index 4b913cd919a..536ed473109 100644 --- a/apps/mobile/src/i18n/locales/zh-TW/models.json +++ b/apps/mobile/src/i18n/locales/zh-TW/models.json @@ -22,7 +22,46 @@ "priceAmounts": "輸入 {{input}} · 輸出 {{output}}", "discountedVsStandard": "折扣中,較標準價省 {{percent}}%", "contextSuffix": "{{size}} 上下文", - "fastTag": "快速" + "fastTag": "快速", + "provenance": { + "title": "欄位來源", + "hint": "由左至右,後面的值覆蓋前面的值。這裡顯示此模型的預設設定;現有任務儲存的選擇保持不變。", + "noTrace": "目前主機未提供欄位來源明細。", + "fields": { + "name": "名稱", + "description": "簡介", + "contextWindow": "使用視窗", + "maxOutputTokens": "最大輸出", + "efforts": "思考檔位", + "defaultEffort": "預設思考檔位", + "supportsFastMode": "Fast 能力", + "supportsImageInput": "圖片輸入", + "defaultFast": "預設 Fast", + "defaultEnabled": "預設顯示", + "preferredAgent": "預設引擎", "mode": "模型類型", "modalities": "輸入 / 輸出", "group": "分組", "officialDocs": "官方文件", "presentation": "發布的名稱與簡介" + }, + "sources": { + "public": "共用型號", + "provider": "供應商預設", + "access": "接入預設", + "engine": "引擎差異", + "discovery": "供應商回報", + "verified": "已核實修正", + "user": "個人設定", + "product": "已發布預設", + "fallback": "內建備用", + "constraint": "能力限制" + }, + "reasons": { + "disconnected": "供應商尚未連線。", + "suspended": "供應商已停用。", + "disabled": "模型已停用,無法用於新請求。", + "retired": "模型已停止使用,無法發起新請求。", + "requires_payment": "目前帳號尚無存取權限,需要訂閱或付費。", + "non_chat": "此模型不支援 Agent 對話。", + "hidden": "已從清單隱藏;隱藏不會禁止明確指定呼叫。" + } + } }, "options": { "fastMode": "快速模式", diff --git a/apps/mobile/src/session/MobileModelPickerList.tsx b/apps/mobile/src/session/MobileModelPickerList.tsx index ed3ab068adb..9b120d9f5a1 100644 --- a/apps/mobile/src/session/MobileModelPickerList.tsx +++ b/apps/mobile/src/session/MobileModelPickerList.tsx @@ -1,3 +1,4 @@ +import { localizedModelPresentation } from '@cindy/model-providers'; /** * MobileModelPickerList —— 模型浮窗一级视图的行列表(新建会话页 + 会话内 composer 共用, * 由 ModelPickerSheet 装配)。 @@ -185,7 +186,7 @@ export function MobileModelPickerList({ }: MobileModelPickerListProps) { const styles = useThemedStyles(makeStyles); const { colors } = useTheme(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const resolvedEmptyHint = emptyHint ?? t('models.picker.emptyDefault'); const resolvedLoadingHint = loadingHint ?? t('models.picker.loadingDefault'); // 非选中行的记忆写入(二级浮窗里改)不经 props 回流 —— 订阅两个记忆 store 的版本号, @@ -236,10 +237,12 @@ export function MobileModelPickerList({ const fullEffortLabel = rowEffort ? effortLabelFor(row.model, rowEffort, capabilities ?? null) : null; + const presentation = localizedModelPresentation(row.model.presentation, i18n.resolvedLanguage ?? i18n.language); + const displayName = presentation?.name ?? row.model.displayName; const rowAccessibilityLabel = modelRowAccessibilityLabel({ baseLabel: t('models.picker.selectProviderModelAccessibility', { provider: row.provider.name, - model: row.model.displayName, + model: displayName, }), subscriptionLabel: isSubscription ? t('models.picker.subscriptionBadge') : null, effortLabel: fullEffortLabel @@ -286,8 +289,9 @@ export function MobileModelPickerList({ /> - {row.model.displayName} + {displayName} + {presentation?.description && {presentation.description}} {isSubscription || rowEffort || fastOn ? ( {isSubscription ? ( @@ -322,7 +326,7 @@ export function MobileModelPickerList({ {selected ? : null} {hasOptions ? ( + {model.fieldSources && + setShowSources(value => !value)} style={{minHeight:44,justifyContent:'center'}}>{t('models.picker.provenance.title')} + {showSources && <> + {t('models.picker.provenance.hint')} + {['name','description','presentation','mode','modalities','group','officialDocs','contextWindow','maxOutputTokens','efforts','defaultEffort','supportsFastMode','supportsImageInput','defaultFast','defaultEnabled','preferredAgent'].filter(field => model.fieldSources?.[field]?.length).map(field => {t(`models.picker.provenance.fields.${field}`)}: {model.fieldSources![field].map(record => `${t(`models.picker.provenance.sources.${record.source}`)}: ${record.value == null ? '—' : typeof record.value === 'object' ? JSON.stringify(record.value) : String(record.value)}`).join(' → ')})} + } + } {metaLine || price ? ( {metaLine ? ( diff --git a/apps/mobile/src/session/modelPickerRows.ts b/apps/mobile/src/session/modelPickerRows.ts index 05c578a43d5..8ca4bf9e51e 100644 --- a/apps/mobile/src/session/modelPickerRows.ts +++ b/apps/mobile/src/session/modelPickerRows.ts @@ -32,6 +32,8 @@ export function budgetDisabledHint(): string { /** 行/展开区消费的最小模型形状(SectionModel 与 capabilities MobileModelOption 都满足)。 */ export interface PickerRowModel { + fieldSources?: import("@cindy/model-providers").CatalogModel["fieldSources"]; + catalogDefaults?: import("@cindy/model-providers").CatalogModel["catalogDefaults"]; defaultFast?: boolean; id: string; efforts: readonly string[]; diff --git a/apps/mobile/src/session/modelPickerSheetModel.ts b/apps/mobile/src/session/modelPickerSheetModel.ts index 12a0e806afb..7e23278e496 100644 --- a/apps/mobile/src/session/modelPickerSheetModel.ts +++ b/apps/mobile/src/session/modelPickerSheetModel.ts @@ -1,3 +1,4 @@ +import { localizedModelPresentation } from '@cindy/model-providers'; /** * modelPickerSheetModel —— 模型选择浮窗的视图状态机与派生纯逻辑(**纯逻辑,零 react-native**)。 * @@ -71,7 +72,7 @@ export function findOptionsTarget( return { model: row.model, provider: row.provider, - displayName: row.model.displayName, + displayName: localizedModelPresentation(row.model.presentation, i18n.resolvedLanguage ?? i18n.language)?.name ?? row.model.displayName, contextWindow: row.model.contextWindow, }; } diff --git a/docs/design-rules/design-inventory.md b/docs/design-rules/design-inventory.md index f6c73ca7138..5d3df300172 100644 --- a/docs/design-rules/design-inventory.md +++ b/docs/design-rules/design-inventory.md @@ -9,7 +9,7 @@ 本区块由 `scripts/design-inventory.mjs` 生成,请勿手改。 重新生成:`pnpm design:inventory`;校验:`pnpm check:design-inventory`。 -计数快照日期:2026-09-09。生成命令:`pnpm design:inventory`。裸颜色匹配与 `scripts/hardcoded-color-audit.mjs` 共用 `scripts/shared/hardcoded-color-match.mjs`(HEX / rgb() / rgba() / hsl() / hsla()),台账统计层额外剔除 `var()` 包装与注释(TS/TSX 剥块注释与整行注释)——语义 token 消费与注释引用不是迁移债务;裸圆角为粗粒度(`rounded*` class、`border-radius:` 与 React style 对象的 `borderRadius:`)。Token 计数为样式源里 `var(--token)` / `hsl(var(--token)` 的去重 ID 数。 +计数快照日期:2026-09-10。生成命令:`pnpm design:inventory`。裸颜色匹配与 `scripts/hardcoded-color-audit.mjs` 共用 `scripts/shared/hardcoded-color-match.mjs`(HEX / rgb() / rgba() / hsl() / hsla()),台账统计层额外剔除 `var()` 包装与注释(TS/TSX 剥块注释与整行注释)——语义 token 消费与注释引用不是迁移债务;裸圆角为粗粒度(`rounded*` class、`border-radius:` 与 React style 对象的 `borderRadius:`)。Token 计数为样式源里 `var(--token)` / `hsl(var(--token)` 的去重 ID 数。 登记 surface 数:35。平台本轮仅 Desktop。 @@ -38,7 +38,7 @@ | `desktop.overlay.toast` | desktop | Toast | App 常驻 ToastContainer(用户可见出口,不展开业务逻辑) | Toast, ToastContainer | apps/desktop/src/renderer/components/ui/toast/Toast.tsx, apps/desktop/src/renderer/components/ui/toast/ToastContainer.tsx, apps/desktop/src/renderer/components/ui/toast/index.ts | 7 | 4 | 3 | | `desktop.plugins.app-main` | desktop | 插件主视图 | hash `/apps/:ghostId`(GhostMainViewFeatureLayout) | GhostMainViewFeatureLayout, GhostMainViewHost | apps/desktop/src/renderer/features/plugin/GhostMainViewFeatureLayout.tsx, apps/desktop/src/renderer/features/plugin/GhostMainViewHost.tsx | 0 | 0 | 0 | | `desktop.plugins.installed` | desktop | 已装插件 | hash `/plugins`(GhostPluginPage) | AddMarketplaceDialog, GhostPluginDetailView, GhostPluginPage, MarketPluginDetailView, MyPublishesSection, PluginManagementLayout, PluginScopePicker, UpdateAllDialog | apps/desktop/src/renderer/features/plugin/AddMarketplaceDialog.tsx, apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx, apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx, apps/desktop/src/renderer/features/plugin/MarketPluginDetailView.tsx, apps/desktop/src/renderer/features/plugin/MyPublishesSection.tsx, apps/desktop/src/renderer/features/plugin/PluginManagementLayout.tsx, apps/desktop/src/renderer/features/plugin/PluginScopePicker.tsx, apps/desktop/src/renderer/features/plugin/UpdateAllDialog.tsx, apps/desktop/src/renderer/features/plugin/plugin-motion.css | 49 | 0 | 82 | -| `desktop.settings` | desktop | 设置 | hash `/settings`(SettingsView;tab 含 general / personalization / providers / billing / usage / voice-input / im-bot / shortcuts / agent-island / import / remote-control / ghosts / builtin-tools / computer-use / help / about) | SettingsView | apps/desktop/src/renderer/components/settings/AboutSection.tsx, apps/desktop/src/renderer/components/settings/AccountDeletionSection.tsx, apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx, apps/desktop/src/renderer/components/settings/AgentIslandSection.tsx, apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx, apps/desktop/src/renderer/components/settings/AppearanceSection.tsx, apps/desktop/src/renderer/components/settings/AuxiliaryModelSection.tsx, apps/desktop/src/renderer/components/settings/BetaChannelCell.tsx, apps/desktop/src/renderer/components/settings/BrowserBackendSubsection.tsx, apps/desktop/src/renderer/components/settings/BrowserRealProfileSubsection.tsx, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.tsx, apps/desktop/src/renderer/components/settings/ChatEmbeddingCell.tsx, apps/desktop/src/renderer/components/settings/ChipMetricsSection.tsx, apps/desktop/src/renderer/components/settings/CindyMakeSection.tsx, apps/desktop/src/renderer/components/settings/CollaborationSection.tsx, apps/desktop/src/renderer/components/settings/CompactionSection.tsx, apps/desktop/src/renderer/components/settings/ComposerSendShortcutSection.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionGuideWindow.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionRow.tsx, apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx, apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx, apps/desktop/src/renderer/components/settings/CustomProviderRuntimeFillOverlay.tsx, apps/desktop/src/renderer/components/settings/DefaultOverrideControls.tsx, apps/desktop/src/renderer/components/settings/DingTalkBotSection.tsx, apps/desktop/src/renderer/components/settings/DiscordBotSection.tsx, apps/desktop/src/renderer/components/settings/DownloadMeter.tsx, apps/desktop/src/renderer/components/settings/ExperimentalSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotNotificationSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotSection.tsx, apps/desktop/src/renderer/components/settings/FontFamilyPicker.tsx, apps/desktop/src/renderer/components/settings/GenericGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/GitSafetySection.tsx, apps/desktop/src/renderer/components/settings/HelpAssistantPanel.tsx, apps/desktop/src/renderer/components/settings/HelpSection.tsx, apps/desktop/src/renderer/components/settings/HelpThreadView.tsx, apps/desktop/src/renderer/components/settings/HookConnectionsSection.tsx, apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx, apps/desktop/src/renderer/components/settings/ImBotSection.tsx, apps/desktop/src/renderer/components/settings/ImChannelSettingsCard.tsx, apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx, apps/desktop/src/renderer/components/settings/ImLifecycleAnnouncementSection.tsx, apps/desktop/src/renderer/components/settings/InputDeviceConnectionStatus.tsx, apps/desktop/src/renderer/components/settings/JoyConGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.tsx, apps/desktop/src/renderer/components/settings/LanguageSection.tsx, apps/desktop/src/renderer/components/settings/LayoutResetControl.tsx, apps/desktop/src/renderer/components/settings/LinkOpenSection.tsx, apps/desktop/src/renderer/components/settings/LocalOllamaInstall.tsx, apps/desktop/src/renderer/components/settings/LocalPackagingTag.tsx, apps/desktop/src/renderer/components/settings/LogoutSection.tsx, apps/desktop/src/renderer/components/settings/LspBetaCell.tsx, apps/desktop/src/renderer/components/settings/McpServerDialog.tsx, apps/desktop/src/renderer/components/settings/McpServersSection.tsx, apps/desktop/src/renderer/components/settings/MemorySection.tsx, apps/desktop/src/renderer/components/settings/MessageNavRailCell.tsx, apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx, apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx, apps/desktop/src/renderer/components/settings/MyDevicesPanel.tsx, apps/desktop/src/renderer/components/settings/NotificationSection.tsx, apps/desktop/src/renderer/components/settings/OAuthDeviceCodeCard.tsx, apps/desktop/src/renderer/components/settings/OllamaProviderDetail.tsx, apps/desktop/src/renderer/components/settings/PiPackagesSection.tsx, apps/desktop/src/renderer/components/settings/PlayStationGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/ProfileEditDialog.tsx, apps/desktop/src/renderer/components/settings/ProvidersSection.tsx, apps/desktop/src/renderer/components/settings/RemoteControlSection.tsx, apps/desktop/src/renderer/components/settings/RemoteDesktopPermissions.tsx, apps/desktop/src/renderer/components/settings/RemoteDesktopSetting.tsx, apps/desktop/src/renderer/components/settings/RemoteHostDetail.tsx, apps/desktop/src/renderer/components/settings/RemoteSection.tsx, apps/desktop/src/renderer/components/settings/SessionImportSection.tsx, apps/desktop/src/renderer/components/settings/SessionRuntimeFallbackCell.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.tsx, apps/desktop/src/renderer/components/settings/SettingsSegmentedControl.tsx, apps/desktop/src/renderer/components/settings/SettingsSidebarNav.tsx, apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx, apps/desktop/src/renderer/components/settings/SettingsView.tsx, apps/desktop/src/renderer/components/settings/SilentEncryptedRetryCell.tsx, apps/desktop/src/renderer/components/settings/SshKeySetupDialog.tsx, apps/desktop/src/renderer/components/settings/StorageManagementCard.tsx, apps/desktop/src/renderer/components/settings/StreamFadeSection.tsx, apps/desktop/src/renderer/components/settings/SubagentModelSection.tsx, apps/desktop/src/renderer/components/settings/SwitchProGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/TelegramBehaviorSettings.tsx, apps/desktop/src/renderer/components/settings/TelegramBotSection.tsx, apps/desktop/src/renderer/components/settings/TelegramRemoteDevices.tsx, apps/desktop/src/renderer/components/settings/TerminalShellSection.tsx, apps/desktop/src/renderer/components/settings/TipsSection.tsx, apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx, apps/desktop/src/renderer/components/settings/UserProfileCard.tsx, apps/desktop/src/renderer/components/settings/UserPromptSection.tsx, apps/desktop/src/renderer/components/settings/VisionBridgeSection.tsx, apps/desktop/src/renderer/components/settings/VoiceInputSection.tsx, apps/desktop/src/renderer/components/settings/WechatBotSection.tsx, apps/desktop/src/renderer/components/settings/WecomBotSection.tsx, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeyboardLayout.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeycapGlyphs.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexSettings.tsx, apps/desktop/src/renderer/components/settings/XUsageGuide.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadSettings.tsx, apps/desktop/src/renderer/components/settings/androidStatusPresentation.ts, apps/desktop/src/renderer/components/settings/billingVisibility.ts, apps/desktop/src/renderer/components/settings/browserOpenForLoginError.ts, apps/desktop/src/renderer/components/settings/computerPermissionFlow.ts, apps/desktop/src/renderer/components/settings/contacts/ContactDetailPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsImportDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsListPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsSection.tsx, apps/desktop/src/renderer/components/settings/contacts/startContactsAiSession.ts, apps/desktop/src/renderer/components/settings/dualSenseSilhouette.ts, apps/desktop/src/renderer/components/settings/feishuBotPresentation.ts, apps/desktop/src/renderer/components/settings/fontFamilyValue.ts, apps/desktop/src/renderer/components/settings/gamepadLayoutPrimitives.tsx, apps/desktop/src/renderer/components/settings/gamepadSilhouetteGeom.ts, apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts, apps/desktop/src/renderer/components/settings/imBotVisibility.ts, apps/desktop/src/renderer/components/settings/imDefaultSettingsLogic.ts, apps/desktop/src/renderer/components/settings/joyConSilhouette.ts, apps/desktop/src/renderer/components/settings/modelManagementPresentation.ts, apps/desktop/src/renderer/components/settings/modelManagementState.ts, apps/desktop/src/renderer/components/settings/myDevicesModel.ts, apps/desktop/src/renderer/components/settings/providerAssetModule.ts, apps/desktop/src/renderer/components/settings/realProfilePermissionGuide.ts, apps/desktop/src/renderer/components/settings/switchProSilhouette.ts, apps/desktop/src/renderer/components/settings/ultimateC1Silhouette.ts, apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx, apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx, apps/desktop/src/renderer/components/settings/usage/formatUsagePercent.ts, apps/desktop/src/renderer/components/settings/usage/usageCharts.css, apps/desktop/src/renderer/components/settings/usage/usageHistoryColors.ts, apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts, apps/desktop/src/renderer/components/settings/usageVisibility.ts, apps/desktop/src/renderer/components/settings/wizardRecommend.ts, apps/desktop/src/renderer/components/settings/workLouderCodexCommandCopy.ts, apps/desktop/src/renderer/components/settings/xboxSeriesSilhouette.ts, apps/desktop/src/renderer/styles/sortable.css | 153 | 121 | 733 | +| `desktop.settings` | desktop | 设置 | hash `/settings`(SettingsView;tab 含 general / personalization / providers / billing / usage / voice-input / im-bot / shortcuts / agent-island / import / remote-control / ghosts / builtin-tools / computer-use / help / about) | SettingsView | apps/desktop/src/renderer/components/settings/AboutSection.tsx, apps/desktop/src/renderer/components/settings/AccountDeletionSection.tsx, apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx, apps/desktop/src/renderer/components/settings/AgentIslandSection.tsx, apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx, apps/desktop/src/renderer/components/settings/AppearanceSection.tsx, apps/desktop/src/renderer/components/settings/AuxiliaryModelSection.tsx, apps/desktop/src/renderer/components/settings/BetaChannelCell.tsx, apps/desktop/src/renderer/components/settings/BrowserBackendSubsection.tsx, apps/desktop/src/renderer/components/settings/BrowserRealProfileSubsection.tsx, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.tsx, apps/desktop/src/renderer/components/settings/ChatEmbeddingCell.tsx, apps/desktop/src/renderer/components/settings/ChipMetricsSection.tsx, apps/desktop/src/renderer/components/settings/CindyMakeSection.tsx, apps/desktop/src/renderer/components/settings/CollaborationSection.tsx, apps/desktop/src/renderer/components/settings/CompactionSection.tsx, apps/desktop/src/renderer/components/settings/ComposerSendShortcutSection.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionGuideWindow.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionRow.tsx, apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx, apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx, apps/desktop/src/renderer/components/settings/CustomProviderRuntimeFillOverlay.tsx, apps/desktop/src/renderer/components/settings/DefaultOverrideControls.tsx, apps/desktop/src/renderer/components/settings/DingTalkBotSection.tsx, apps/desktop/src/renderer/components/settings/DiscordBotSection.tsx, apps/desktop/src/renderer/components/settings/DownloadMeter.tsx, apps/desktop/src/renderer/components/settings/ExperimentalSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotNotificationSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotSection.tsx, apps/desktop/src/renderer/components/settings/FontFamilyPicker.tsx, apps/desktop/src/renderer/components/settings/GenericGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/GitSafetySection.tsx, apps/desktop/src/renderer/components/settings/HelpAssistantPanel.tsx, apps/desktop/src/renderer/components/settings/HelpSection.tsx, apps/desktop/src/renderer/components/settings/HelpThreadView.tsx, apps/desktop/src/renderer/components/settings/HookConnectionsSection.tsx, apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx, apps/desktop/src/renderer/components/settings/ImBotSection.tsx, apps/desktop/src/renderer/components/settings/ImChannelSettingsCard.tsx, apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx, apps/desktop/src/renderer/components/settings/ImLifecycleAnnouncementSection.tsx, apps/desktop/src/renderer/components/settings/InputDeviceConnectionStatus.tsx, apps/desktop/src/renderer/components/settings/JoyConGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.tsx, apps/desktop/src/renderer/components/settings/LanguageSection.tsx, apps/desktop/src/renderer/components/settings/LayoutResetControl.tsx, apps/desktop/src/renderer/components/settings/LinkOpenSection.tsx, apps/desktop/src/renderer/components/settings/LocalOllamaInstall.tsx, apps/desktop/src/renderer/components/settings/LocalPackagingTag.tsx, apps/desktop/src/renderer/components/settings/LogoutSection.tsx, apps/desktop/src/renderer/components/settings/LspBetaCell.tsx, apps/desktop/src/renderer/components/settings/McpServerDialog.tsx, apps/desktop/src/renderer/components/settings/McpServersSection.tsx, apps/desktop/src/renderer/components/settings/MemorySection.tsx, apps/desktop/src/renderer/components/settings/MessageNavRailCell.tsx, apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx, apps/desktop/src/renderer/components/settings/ModelFieldSources.tsx, apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx, apps/desktop/src/renderer/components/settings/MyDevicesPanel.tsx, apps/desktop/src/renderer/components/settings/NotificationSection.tsx, apps/desktop/src/renderer/components/settings/OAuthDeviceCodeCard.tsx, apps/desktop/src/renderer/components/settings/OllamaProviderDetail.tsx, apps/desktop/src/renderer/components/settings/PiPackagesSection.tsx, apps/desktop/src/renderer/components/settings/PlayStationGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/ProfileEditDialog.tsx, apps/desktop/src/renderer/components/settings/ProvidersSection.tsx, apps/desktop/src/renderer/components/settings/RemoteControlSection.tsx, apps/desktop/src/renderer/components/settings/RemoteDesktopPermissions.tsx, apps/desktop/src/renderer/components/settings/RemoteDesktopSetting.tsx, apps/desktop/src/renderer/components/settings/RemoteHostDetail.tsx, apps/desktop/src/renderer/components/settings/RemoteSection.tsx, apps/desktop/src/renderer/components/settings/SessionImportSection.tsx, apps/desktop/src/renderer/components/settings/SessionRuntimeFallbackCell.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.tsx, apps/desktop/src/renderer/components/settings/SettingsSegmentedControl.tsx, apps/desktop/src/renderer/components/settings/SettingsSidebarNav.tsx, apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx, apps/desktop/src/renderer/components/settings/SettingsView.tsx, apps/desktop/src/renderer/components/settings/SilentEncryptedRetryCell.tsx, apps/desktop/src/renderer/components/settings/SshKeySetupDialog.tsx, apps/desktop/src/renderer/components/settings/StorageManagementCard.tsx, apps/desktop/src/renderer/components/settings/StreamFadeSection.tsx, apps/desktop/src/renderer/components/settings/SubagentModelSection.tsx, apps/desktop/src/renderer/components/settings/SwitchProGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/TelegramBehaviorSettings.tsx, apps/desktop/src/renderer/components/settings/TelegramBotSection.tsx, apps/desktop/src/renderer/components/settings/TelegramRemoteDevices.tsx, apps/desktop/src/renderer/components/settings/TerminalShellSection.tsx, apps/desktop/src/renderer/components/settings/TipsSection.tsx, apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx, apps/desktop/src/renderer/components/settings/UserProfileCard.tsx, apps/desktop/src/renderer/components/settings/UserPromptSection.tsx, apps/desktop/src/renderer/components/settings/VisionBridgeSection.tsx, apps/desktop/src/renderer/components/settings/VoiceInputSection.tsx, apps/desktop/src/renderer/components/settings/WechatBotSection.tsx, apps/desktop/src/renderer/components/settings/WecomBotSection.tsx, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeyboardLayout.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeycapGlyphs.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexSettings.tsx, apps/desktop/src/renderer/components/settings/XUsageGuide.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadSettings.tsx, apps/desktop/src/renderer/components/settings/androidStatusPresentation.ts, apps/desktop/src/renderer/components/settings/billingVisibility.ts, apps/desktop/src/renderer/components/settings/browserOpenForLoginError.ts, apps/desktop/src/renderer/components/settings/computerPermissionFlow.ts, apps/desktop/src/renderer/components/settings/contacts/ContactDetailPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsImportDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsListPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsSection.tsx, apps/desktop/src/renderer/components/settings/contacts/startContactsAiSession.ts, apps/desktop/src/renderer/components/settings/dualSenseSilhouette.ts, apps/desktop/src/renderer/components/settings/feishuBotPresentation.ts, apps/desktop/src/renderer/components/settings/fontFamilyValue.ts, apps/desktop/src/renderer/components/settings/gamepadLayoutPrimitives.tsx, apps/desktop/src/renderer/components/settings/gamepadSilhouetteGeom.ts, apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts, apps/desktop/src/renderer/components/settings/imBotVisibility.ts, apps/desktop/src/renderer/components/settings/imDefaultSettingsLogic.ts, apps/desktop/src/renderer/components/settings/joyConSilhouette.ts, apps/desktop/src/renderer/components/settings/modelManagementPresentation.ts, apps/desktop/src/renderer/components/settings/modelManagementState.ts, apps/desktop/src/renderer/components/settings/myDevicesModel.ts, apps/desktop/src/renderer/components/settings/providerAssetModule.ts, apps/desktop/src/renderer/components/settings/realProfilePermissionGuide.ts, apps/desktop/src/renderer/components/settings/switchProSilhouette.ts, apps/desktop/src/renderer/components/settings/ultimateC1Silhouette.ts, apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx, apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx, apps/desktop/src/renderer/components/settings/usage/formatUsagePercent.ts, apps/desktop/src/renderer/components/settings/usage/usageCharts.css, apps/desktop/src/renderer/components/settings/usage/usageHistoryColors.ts, apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts, apps/desktop/src/renderer/components/settings/usageVisibility.ts, apps/desktop/src/renderer/components/settings/wizardRecommend.ts, apps/desktop/src/renderer/components/settings/workLouderCodexCommandCopy.ts, apps/desktop/src/renderer/components/settings/xboxSeriesSilhouette.ts, apps/desktop/src/renderer/styles/sortable.css | 153 | 121 | 733 | | `desktop.shell.main-layout` | desktop | 主窗口壳(标题栏 / 左右栏 / 内容区) | main BrowserWindow → renderer/index.tsx → main-entry.tsx → App → MainLayout (`#/` 受保护壳) | ChromeActions, FeishuConflictDialogHost, GhostMediaLightboxHost, MainLayout, RightSidebar, RightSidebarShell, SessionShareImportWizard, Sidebar, UpdateNoticeDialog, WindowControls | apps/desktop/src/renderer/cindy-brain/GhostMediaLightboxHost.tsx, apps/desktop/src/renderer/components/UpdateNoticeDialog.tsx, apps/desktop/src/renderer/components/feishuBot/FeishuConflictDialog.tsx, apps/desktop/src/renderer/components/feishuBot/FeishuConflictDialogHost.tsx, apps/desktop/src/renderer/components/layout/BrowserWebviewPool.tsx, apps/desktop/src/renderer/components/layout/ChromeActions.tsx, apps/desktop/src/renderer/components/layout/ContentHeader.tsx, apps/desktop/src/renderer/components/layout/CredentialStoreBanner.tsx, apps/desktop/src/renderer/components/layout/FadeSwitcher.tsx, apps/desktop/src/renderer/components/layout/GhostPanelWindowLayout.tsx, apps/desktop/src/renderer/components/layout/GlobalDropImportListener.tsx, apps/desktop/src/renderer/components/layout/MainLayout.tsx, apps/desktop/src/renderer/components/layout/RightSidebar.tsx, apps/desktop/src/renderer/components/layout/RightSidebarDetach.tsx, apps/desktop/src/renderer/components/layout/RightSidebarMaximize.tsx, apps/desktop/src/renderer/components/layout/RightSidebarToggle.tsx, apps/desktop/src/renderer/components/layout/SidebarWindowLayout.tsx, apps/desktop/src/renderer/components/layout/chromeActionsGeometry.ts, apps/desktop/src/renderer/components/layout/chromeActionsLayout.ts, apps/desktop/src/renderer/components/layout/railChromeActions.ts, apps/desktop/src/renderer/components/layout/windowDrag.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/sidebar/AccountSwitcherDialog.tsx, apps/desktop/src/renderer/components/sidebar/AttentionDot.tsx, apps/desktop/src/renderer/components/sidebar/DatabaseSizeWarningBanner.tsx, apps/desktop/src/renderer/components/sidebar/GhostMainViewNavEntries.tsx, apps/desktop/src/renderer/components/sidebar/MobileDownloadDialog.tsx, apps/desktop/src/renderer/components/sidebar/Sidebar.tsx, apps/desktop/src/renderer/components/sidebar/SidebarIconButton.tsx, apps/desktop/src/renderer/components/sidebar/SidebarTopNav.tsx, apps/desktop/src/renderer/components/sidebar/SortableList.tsx, apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx, apps/desktop/src/renderer/components/sidebar/UserInfoSection.tsx, apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx, apps/desktop/src/renderer/components/sidebar/VendorReadinessBadge.tsx, apps/desktop/src/renderer/components/sidebar/WorktreeBadge.tsx, apps/desktop/src/renderer/components/title-bar/ChromeIconButton.tsx, apps/desktop/src/renderer/components/title-bar/MenuButton.tsx, apps/desktop/src/renderer/components/title-bar/WindowControls.tsx, apps/desktop/src/renderer/features/right-sidebar/AddTabDropdown.tsx, apps/desktop/src/renderer/features/right-sidebar/EmptyState.tsx, apps/desktop/src/renderer/features/right-sidebar/RightSidebarShell.tsx, apps/desktop/src/renderer/features/right-sidebar/TabBar.tsx, apps/desktop/src/renderer/features/right-sidebar/TabBodyErrorBoundary.tsx, apps/desktop/src/renderer/features/right-sidebar/hooks/useBrowserWebview.ts, apps/desktop/src/renderer/features/right-sidebar/hooks/useNativePopupSurface.ts, apps/desktop/src/renderer/features/right-sidebar/iosSimulatorPluginAvailability.ts, apps/desktop/src/renderer/features/right-sidebar/lib/browserPartition.ts, apps/desktop/src/renderer/features/right-sidebar/lib/browserWebviewPool.ts, apps/desktop/src/renderer/features/right-sidebar/lib/detachedSidebarRouting.ts, apps/desktop/src/renderer/features/right-sidebar/lib/executeSidebarCommand.ts, apps/desktop/src/renderer/features/right-sidebar/lib/iosSimulatorFocusBridge.ts, apps/desktop/src/renderer/features/right-sidebar/lib/lastTurnChangedFiles.ts, apps/desktop/src/renderer/features/right-sidebar/lib/nativePopupTabs.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openBackgroundTasksTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openInSidebarBrowser.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openInSidebarFileBrowser.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openRoutinesTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openSubagentsTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openTerminalShortcut.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openTurnReview.ts, apps/desktop/src/renderer/features/right-sidebar/lib/popupRouter.ts, apps/desktop/src/renderer/features/right-sidebar/lib/popupTabs.ts, apps/desktop/src/renderer/features/right-sidebar/lib/rsbBrowserBridge.ts, apps/desktop/src/renderer/features/right-sidebar/lib/sidebarCommands.ts, apps/desktop/src/renderer/features/right-sidebar/lib/sidebarHostSession.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/BackgroundTasksBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/WorkflowAgentStrip.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/WorkflowProgressTree.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/chatTaskFocusIntent.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/workflowProgressModel.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/FileBrowserBody.css, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/FileBrowserBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/dropExternalFile.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/fileTreeImagePreview.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/useSessionScopedTreeWidth.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/index.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/IOSSimulatorInstanceGrid.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/IOSSimulatorTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/iosSimulatorH264Decoder.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/OrcaWorkersAttentionIcon.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/OrcaWorkersTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/actions.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/closeDecision.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/resource-usage.css, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/subscription.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/ImageDiffPreview.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/MarkdownDiffPreview.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/PlainUnifiedDiff.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/diffRows.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/highlight.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/inlineDiff.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/useDiffHighlights.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/ReviewTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/diffExpansionPreference.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/fileTree.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/gitApplyCommand.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/markdownPreview.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useLastTurnFilter.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useReviewGitState.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useReviewSource.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/routines/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/ConversationStream.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/DetailView.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/RunList.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentChrome.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentToolCard.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentsBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentChangeFence.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentConversation.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentFormat.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/TerminalTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/lib/xtermPool.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserChrome.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserCommentPopover.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/browserCommentEditorDraft.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/lib/parseOmnibox.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/useBrowserComment.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/useLocalHtmlAutoReload.ts, apps/desktop/src/renderer/features/right-sidebar/registry.ts, apps/desktop/src/renderer/features/right-sidebar/store.ts, apps/desktop/src/renderer/features/right-sidebar/types.ts, apps/desktop/src/renderer/layout/LayoutRoot.tsx, apps/desktop/src/renderer/layout/PanelDragController.tsx, apps/desktop/src/renderer/layout/PanelDragPrototype.tsx, apps/desktop/src/renderer/layout/collapsePrefs.ts, apps/desktop/src/renderer/layout/layoutDevTools.ts, apps/desktop/src/renderer/layout/panePlacement.tsx, apps/desktop/src/renderer/layout/paneWidths.tsx, apps/desktop/src/renderer/layout/panelMaximize.tsx, apps/desktop/src/renderer/styles/globals.css | 145 | 72 | 290 | | `desktop.skillhub.local` | desktop | SkillHub 本地技能 | hash `/skillhub/local` 及详情 `/skillhub/local/:kind/global/:name`、`/skillhub/local/:kind/project/:projectHash/:name` | InstallTargetPicker, PluginManagementLayout, SkillhubDetailView, SkillhubFeatureLayout, SkillhubHomeView, SkillhubMarketPreviewPanel | apps/desktop/src/renderer/features/plugin/PluginManagementLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubFeatureLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx | 41 | 0 | 66 | | `desktop.skillhub.market` | desktop | SkillHub 市场 | hash `/skillhub/market`(SkillhubMarketListView) | InstallTargetPicker, MarketCard, MarketInfoEditDialog, SkillhubMarketListView, SkillhubMarketPreviewPanel, VisibilityEditorDialog | apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx, apps/desktop/src/renderer/features/skillhub/components/HomeMarketCard.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx, apps/desktop/src/renderer/features/skillhub/components/LocalSkillControls.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketCard.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketInfoEditDialog.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketLocalSkills.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketPreviewTree.tsx, apps/desktop/src/renderer/features/skillhub/components/PlatformTagSelector.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillCategoryFilterBar.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillIcon.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillTagList.tsx, apps/desktop/src/renderer/features/skillhub/components/TeamScopePicker.tsx, apps/desktop/src/renderer/features/skillhub/components/VisibilityEditorDialog.tsx | 47 | 0 | 63 | diff --git a/docs/dev-rules/model-catalog-maintenance.md b/docs/dev-rules/model-catalog-maintenance.md index 77d110ac01f..595d38fc70d 100644 --- a/docs/dev-rules/model-catalog-maintenance.md +++ b/docs/dev-rules/model-catalog-maintenance.md @@ -302,3 +302,14 @@ GPT `[1m]` 是旧窗口预设,退出 Desktop 管理和新选择清单;完整 仍需完成,不能声称线上已经更新。后续 Server revision 应携带这些已核实字段,防止回退。 置灰只能证明当前目录未声明可用,不等于已实测后端拒绝。模型管理页未启用分区始终展开,旧折叠偏好不再隐藏这些型号。 + + +## 数据库发布目录(Registry V5) + +日常公共模型资料、产品默认、已审阅多语言文案和外部供应商模板在 Server 的模型管理页面编辑并发布。CN 主写、Global HTTP 复制到本区域数据库;客户端继续读取自己的区域。Cindy AI 的区域成员、权限和实价不进入共用配置。 + +模板的显式 `modelRef` 只在原模板端点、协议、路径和路由仍匹配时继承。用户的窗口、推理强度、Fast、命名等覆盖优先,Pi 的成员与原生能力保持独立;可见性仍遵循首次初始化例外。新增协议或引擎能力必须实现并验证适配,不能仅增加配置枚举。 + +公共配置与账号发现分开刷新:活跃 Desktop 每 5 分钟尝试拉目录,手动刷新先刷新目录;账号发现保留独立冷却。高级详情的字段来源解释配置装配过程,版本回执不代表引擎已经执行新值。随包文件由同一发布 API 导出,仅作断网和旧目录兜底。 + +修改契约时同步维护两仓 `fixtures/published-catalog` 并各自运行单测;`node scripts/check-model-catalog-contract.mjs /absolute/path/to/peer-worktree` 比较共用 fixture。Server 的 `docs/model-catalog-database.md` 记录增量 migration、首次迁移、启用、区域核验和跨仓验收步骤。 diff --git a/packages/maker-core/src/types/capabilities.ts b/packages/maker-core/src/types/capabilities.ts index 431262fb6d9..295ebbef60a 100644 --- a/packages/maker-core/src/types/capabilities.ts +++ b/packages/maker-core/src/types/capabilities.ts @@ -225,6 +225,9 @@ export interface MemoryCapability { } export interface ModelDescriptor { + defaultFast?: boolean; + /** Published editorial copy; never used to choose a wire model or infer capabilities. */ + presentation?: import("@cindy/model-providers").ModelPresentation; id: string; displayName: string; description?: string; diff --git a/packages/maker-shared/src/agentCapabilities.ts b/packages/maker-shared/src/agentCapabilities.ts index c62ed3d6241..155f1f893e7 100644 --- a/packages/maker-shared/src/agentCapabilities.ts +++ b/packages/maker-shared/src/agentCapabilities.ts @@ -1,4 +1,5 @@ export interface MobileModelOption { + defaultFast?: boolean; id: string; label: string; description?: string; @@ -361,6 +362,7 @@ function normalizeModelOption(value: unknown): MobileModelOption | null { effortDisplayNames, defaultEffort: readString(value.defaultEffort), supportsFastMode: value.supportsFastMode === true, + ...(typeof value.defaultFast === "boolean" ? { defaultFast: value.defaultFast && value.supportsFastMode === true } : {}), ...(contextWindow ? { contextWindow } : {}), ...(newSessionDefault.length > 0 ? { newSessionDefault } : {}), }; diff --git a/packages/model-providers/fixtures/published-catalog/invalid-ambiguous-model-alias.json b/packages/model-providers/fixtures/published-catalog/invalid-ambiguous-model-alias.json new file mode 100644 index 00000000000..423db479413 --- /dev/null +++ b/packages/model-providers/fixtures/published-catalog/invalid-ambiguous-model-alias.json @@ -0,0 +1,105 @@ +{ + "version": "catalog-1", + "providers": [ + { + "id": "openai", + "name": "OpenAI", + "source": "builtin", + "agents": [ + "codex" + ], + "auth": { + "method": "oauth" + }, + "routing": { + "codex": { + "upstream": "https://api.example.com", + "authStrategy": "oauth-passthrough" + } + }, + "models": { + "codex": [] + } + } + ], + "presets": [ + { + "id": "example", + "name": "Example", + "runtimes": { + "codex": { + "baseUrl": "https://api.example.com/v1", + "models": [ + { + "id": "test-model", + "name": "Test Model", + "modelRef": "test-family" + } + ] + } + } + } + ], + "modelRegistry": { + "schemaVersion": 5, + "updatedAt": "2026-09-10T00:00:00.000Z", + "disabledPresetIds": [ + "withdrawn" + ], + "baseModels": [ + { + "id": "test-family", + "aliases": [ + "test-family" + ], + "defaults": { + "contextWindow": 1000000, + "maxOutputTokens": 64000 + }, + "presentation": { + "en": { + "description": "Reviewed shared copy" + }, + "zh-CN": { + "description": "已编辑的共用简介" + } + } + } + ], + "models": [ + { + "id": "test-model", + "modelRef": "test-family", + "name": "Test Model", + "status": "active", + "efforts": [ + "low", + "high" + ], + "defaultEffort": "high", + "supportsFastMode": true, + "routes": [ + { + "providerId": "openai", + "modelId": "test-model", + "agents": [ + "codex" + ] + } + ], + "productDefaults": { + "visible": false, + "contextWindow": 272000, + "effort": "low", + "fast": true, + "preferredAgent": "codex", + "presentation": { + "en": { + "name": "Published name" + } + } + } + } + ] + } +} diff --git a/packages/model-providers/fixtures/published-catalog/invalid-invalid-product-default.json b/packages/model-providers/fixtures/published-catalog/invalid-invalid-product-default.json new file mode 100644 index 00000000000..c19951032f6 --- /dev/null +++ b/packages/model-providers/fixtures/published-catalog/invalid-invalid-product-default.json @@ -0,0 +1,103 @@ +{ + "version": "catalog-1", + "providers": [ + { + "id": "openai", + "name": "OpenAI", + "source": "builtin", + "agents": [ + "codex" + ], + "auth": { + "method": "oauth" + }, + "routing": { + "codex": { + "upstream": "https://api.example.com", + "authStrategy": "oauth-passthrough" + } + }, + "models": { + "codex": [] + } + } + ], + "presets": [ + { + "id": "example", + "name": "Example", + "runtimes": { + "codex": { + "baseUrl": "https://api.example.com/v1", + "models": [ + { + "id": "test-model", + "name": "Test Model", + "modelRef": "test-family" + } + ] + } + } + } + ], + "modelRegistry": { + "schemaVersion": 5, + "updatedAt": "2026-09-10T00:00:00.000Z", + "disabledPresetIds": [ + "withdrawn" + ], + "baseModels": [ + { + "id": "test-family", + "aliases": [], + "defaults": { + "contextWindow": 1000000, + "maxOutputTokens": 64000 + }, + "presentation": { + "en": { + "description": "Reviewed shared copy" + }, + "zh-CN": { + "description": "已编辑的共用简介" + } + } + } + ], + "models": [ + { + "id": "test-model", + "modelRef": "test-family", + "name": "Test Model", + "status": "active", + "efforts": [ + "low", + "high" + ], + "defaultEffort": "high", + "supportsFastMode": true, + "routes": [ + { + "providerId": "openai", + "modelId": "test-model", + "agents": [ + "codex" + ] + } + ], + "productDefaults": { + "visible": false, + "contextWindow": 272000, + "effort": "low", + "fast": "false", + "preferredAgent": "codex", + "presentation": { + "en": { + "name": "Published name" + } + } + } + } + ] + } +} diff --git a/packages/model-providers/fixtures/published-catalog/invalid-unknown-top-level.json b/packages/model-providers/fixtures/published-catalog/invalid-unknown-top-level.json new file mode 100644 index 00000000000..06d80108ac9 --- /dev/null +++ b/packages/model-providers/fixtures/published-catalog/invalid-unknown-top-level.json @@ -0,0 +1,104 @@ +{ + "version": "catalog-1", + "providers": [ + { + "id": "openai", + "name": "OpenAI", + "source": "builtin", + "agents": [ + "codex" + ], + "auth": { + "method": "oauth" + }, + "routing": { + "codex": { + "upstream": "https://api.example.com", + "authStrategy": "oauth-passthrough" + } + }, + "models": { + "codex": [] + } + } + ], + "presets": [ + { + "id": "example", + "name": "Example", + "runtimes": { + "codex": { + "baseUrl": "https://api.example.com/v1", + "models": [ + { + "id": "test-model", + "name": "Test Model", + "modelRef": "test-family" + } + ] + } + } + } + ], + "modelRegistry": { + "schemaVersion": 5, + "updatedAt": "2026-09-10T00:00:00.000Z", + "disabledPresetIds": [ + "withdrawn" + ], + "baseModels": [ + { + "id": "test-family", + "aliases": [], + "defaults": { + "contextWindow": 1000000, + "maxOutputTokens": 64000 + }, + "presentation": { + "en": { + "description": "Reviewed shared copy" + }, + "zh-CN": { + "description": "已编辑的共用简介" + } + } + } + ], + "models": [ + { + "id": "test-model", + "modelRef": "test-family", + "name": "Test Model", + "status": "active", + "efforts": [ + "low", + "high" + ], + "defaultEffort": "high", + "supportsFastMode": true, + "routes": [ + { + "providerId": "openai", + "modelId": "test-model", + "agents": [ + "codex" + ] + } + ], + "productDefaults": { + "visible": false, + "contextWindow": 272000, + "effort": "low", + "fast": true, + "preferredAgent": "codex", + "presentation": { + "en": { + "name": "Published name" + } + } + } + } + ] + }, + "privateKey": "invalid" +} diff --git a/packages/model-providers/fixtures/published-catalog/invalid-unresolved-model-reference.json b/packages/model-providers/fixtures/published-catalog/invalid-unresolved-model-reference.json new file mode 100644 index 00000000000..8d45f324d04 --- /dev/null +++ b/packages/model-providers/fixtures/published-catalog/invalid-unresolved-model-reference.json @@ -0,0 +1,103 @@ +{ + "version": "catalog-1", + "providers": [ + { + "id": "openai", + "name": "OpenAI", + "source": "builtin", + "agents": [ + "codex" + ], + "auth": { + "method": "oauth" + }, + "routing": { + "codex": { + "upstream": "https://api.example.com", + "authStrategy": "oauth-passthrough" + } + }, + "models": { + "codex": [] + } + } + ], + "presets": [ + { + "id": "example", + "name": "Example", + "runtimes": { + "codex": { + "baseUrl": "https://api.example.com/v1", + "models": [ + { + "id": "test-model", + "name": "Test Model", + "modelRef": "test-family" + } + ] + } + } + } + ], + "modelRegistry": { + "schemaVersion": 5, + "updatedAt": "2026-09-10T00:00:00.000Z", + "disabledPresetIds": [ + "withdrawn" + ], + "baseModels": [ + { + "id": "test-family", + "aliases": [], + "defaults": { + "contextWindow": 1000000, + "maxOutputTokens": 64000 + }, + "presentation": { + "en": { + "description": "Reviewed shared copy" + }, + "zh-CN": { + "description": "已编辑的共用简介" + } + } + } + ], + "models": [ + { + "id": "test-model", + "modelRef": "missing", + "name": "Test Model", + "status": "active", + "efforts": [ + "low", + "high" + ], + "defaultEffort": "high", + "supportsFastMode": true, + "routes": [ + { + "providerId": "openai", + "modelId": "test-model", + "agents": [ + "codex" + ] + } + ], + "productDefaults": { + "visible": false, + "contextWindow": 272000, + "effort": "low", + "fast": true, + "preferredAgent": "codex", + "presentation": { + "en": { + "name": "Published name" + } + } + } + } + ] + } +} diff --git a/packages/model-providers/fixtures/published-catalog/invalid-unresolved-preset-reference.json b/packages/model-providers/fixtures/published-catalog/invalid-unresolved-preset-reference.json new file mode 100644 index 00000000000..86844573c76 --- /dev/null +++ b/packages/model-providers/fixtures/published-catalog/invalid-unresolved-preset-reference.json @@ -0,0 +1,103 @@ +{ + "version": "catalog-1", + "providers": [ + { + "id": "openai", + "name": "OpenAI", + "source": "builtin", + "agents": [ + "codex" + ], + "auth": { + "method": "oauth" + }, + "routing": { + "codex": { + "upstream": "https://api.example.com", + "authStrategy": "oauth-passthrough" + } + }, + "models": { + "codex": [] + } + } + ], + "presets": [ + { + "id": "example", + "name": "Example", + "runtimes": { + "codex": { + "baseUrl": "https://api.example.com/v1", + "models": [ + { + "id": "test-model", + "name": "Test Model", + "modelRef": "missing" + } + ] + } + } + } + ], + "modelRegistry": { + "schemaVersion": 5, + "updatedAt": "2026-09-10T00:00:00.000Z", + "disabledPresetIds": [ + "withdrawn" + ], + "baseModels": [ + { + "id": "test-family", + "aliases": [], + "defaults": { + "contextWindow": 1000000, + "maxOutputTokens": 64000 + }, + "presentation": { + "en": { + "description": "Reviewed shared copy" + }, + "zh-CN": { + "description": "已编辑的共用简介" + } + } + } + ], + "models": [ + { + "id": "test-model", + "modelRef": "test-family", + "name": "Test Model", + "status": "active", + "efforts": [ + "low", + "high" + ], + "defaultEffort": "high", + "supportsFastMode": true, + "routes": [ + { + "providerId": "openai", + "modelId": "test-model", + "agents": [ + "codex" + ] + } + ], + "productDefaults": { + "visible": false, + "contextWindow": 272000, + "effort": "low", + "fast": true, + "preferredAgent": "codex", + "presentation": { + "en": { + "name": "Published name" + } + } + } + } + ] + } +} diff --git a/packages/model-providers/fixtures/published-catalog/invalid-unsupported-locale.json b/packages/model-providers/fixtures/published-catalog/invalid-unsupported-locale.json new file mode 100644 index 00000000000..a908d52661c --- /dev/null +++ b/packages/model-providers/fixtures/published-catalog/invalid-unsupported-locale.json @@ -0,0 +1,100 @@ +{ + "version": "catalog-1", + "providers": [ + { + "id": "openai", + "name": "OpenAI", + "source": "builtin", + "agents": [ + "codex" + ], + "auth": { + "method": "oauth" + }, + "routing": { + "codex": { + "upstream": "https://api.example.com", + "authStrategy": "oauth-passthrough" + } + }, + "models": { + "codex": [] + } + } + ], + "presets": [ + { + "id": "example", + "name": "Example", + "runtimes": { + "codex": { + "baseUrl": "https://api.example.com/v1", + "models": [ + { + "id": "test-model", + "name": "Test Model", + "modelRef": "test-family" + } + ] + } + } + } + ], + "modelRegistry": { + "schemaVersion": 5, + "updatedAt": "2026-09-10T00:00:00.000Z", + "disabledPresetIds": [ + "withdrawn" + ], + "baseModels": [ + { + "id": "test-family", + "aliases": [], + "defaults": { + "contextWindow": 1000000, + "maxOutputTokens": 64000 + }, + "presentation": { + "fr": { + "description": "unsupported" + } + } + } + ], + "models": [ + { + "id": "test-model", + "modelRef": "test-family", + "name": "Test Model", + "status": "active", + "efforts": [ + "low", + "high" + ], + "defaultEffort": "high", + "supportsFastMode": true, + "routes": [ + { + "providerId": "openai", + "modelId": "test-model", + "agents": [ + "codex" + ] + } + ], + "productDefaults": { + "visible": false, + "contextWindow": 272000, + "effort": "low", + "fast": true, + "preferredAgent": "codex", + "presentation": { + "en": { + "name": "Published name" + } + } + } + } + ] + } +} diff --git a/packages/model-providers/fixtures/published-catalog/invalid-v4-new-field.json b/packages/model-providers/fixtures/published-catalog/invalid-v4-new-field.json new file mode 100644 index 00000000000..af579876312 --- /dev/null +++ b/packages/model-providers/fixtures/published-catalog/invalid-v4-new-field.json @@ -0,0 +1,103 @@ +{ + "version": "catalog-1", + "providers": [ + { + "id": "openai", + "name": "OpenAI", + "source": "builtin", + "agents": [ + "codex" + ], + "auth": { + "method": "oauth" + }, + "routing": { + "codex": { + "upstream": "https://api.example.com", + "authStrategy": "oauth-passthrough" + } + }, + "models": { + "codex": [] + } + } + ], + "presets": [ + { + "id": "example", + "name": "Example", + "runtimes": { + "codex": { + "baseUrl": "https://api.example.com/v1", + "models": [ + { + "id": "test-model", + "name": "Test Model", + "modelRef": "test-family" + } + ] + } + } + } + ], + "modelRegistry": { + "schemaVersion": 4, + "updatedAt": "2026-09-10T00:00:00.000Z", + "disabledPresetIds": [ + "withdrawn" + ], + "baseModels": [ + { + "id": "test-family", + "aliases": [], + "defaults": { + "contextWindow": 1000000, + "maxOutputTokens": 64000 + }, + "presentation": { + "en": { + "description": "Reviewed shared copy" + }, + "zh-CN": { + "description": "已编辑的共用简介" + } + } + } + ], + "models": [ + { + "id": "test-model", + "modelRef": "test-family", + "name": "Test Model", + "status": "active", + "efforts": [ + "low", + "high" + ], + "defaultEffort": "high", + "supportsFastMode": true, + "routes": [ + { + "providerId": "openai", + "modelId": "test-model", + "agents": [ + "codex" + ] + } + ], + "productDefaults": { + "visible": false, + "contextWindow": 272000, + "effort": "low", + "fast": true, + "preferredAgent": "codex", + "presentation": { + "en": { + "name": "Published name" + } + } + } + } + ] + } +} diff --git a/packages/model-providers/fixtures/published-catalog/manifest.json b/packages/model-providers/fixtures/published-catalog/manifest.json new file mode 100644 index 00000000000..ad2aa949d9a --- /dev/null +++ b/packages/model-providers/fixtures/published-catalog/manifest.json @@ -0,0 +1,65 @@ +{ + "version": 1, + "cases": [ + { + "file": "valid-v1.json", + "valid": true, + "description": "Registry V1" + }, + { + "file": "valid-v2.json", + "valid": true, + "description": "Registry V2" + }, + { + "file": "valid-v3.json", + "valid": true, + "description": "Registry V3" + }, + { + "file": "valid-v4.json", + "valid": true, + "description": "Registry V4" + }, + { + "file": "valid-v5.json", + "valid": true, + "description": "Registry V5" + }, + { + "file": "invalid-unsupported-locale.json", + "valid": false, + "description": "unsupported-locale" + }, + { + "file": "invalid-invalid-product-default.json", + "valid": false, + "description": "invalid-product-default" + }, + { + "file": "invalid-unresolved-preset-reference.json", + "valid": false, + "description": "unresolved-preset-reference" + }, + { + "file": "invalid-unresolved-model-reference.json", + "valid": false, + "description": "unresolved-model-reference" + }, + { + "file": "invalid-ambiguous-model-alias.json", + "valid": false, + "description": "ambiguous-model-alias" + }, + { + "file": "invalid-unknown-top-level.json", + "valid": false, + "description": "unknown-top-level" + }, + { + "file": "invalid-v4-new-field.json", + "valid": false, + "description": "v4-new-field" + } + ] +} diff --git a/packages/model-providers/fixtures/published-catalog/valid-v1.json b/packages/model-providers/fixtures/published-catalog/valid-v1.json new file mode 100644 index 00000000000..9a6bc95c47a --- /dev/null +++ b/packages/model-providers/fixtures/published-catalog/valid-v1.json @@ -0,0 +1,72 @@ +{ + "version": "catalog-1", + "providers": [ + { + "id": "openai", + "name": "OpenAI", + "source": "builtin", + "agents": [ + "codex" + ], + "auth": { + "method": "oauth" + }, + "routing": { + "codex": { + "upstream": "https://api.example.com", + "authStrategy": "oauth-passthrough" + } + }, + "models": { + "codex": [] + } + } + ], + "presets": [ + { + "id": "example", + "name": "Example", + "runtimes": { + "codex": { + "baseUrl": "https://api.example.com/v1", + "models": [ + { + "contextWindow": 1000000, + "maxOutputTokens": 64000, + "id": "test-model", + "name": "Test Model" + } + ] + } + } + } + ], + "modelRegistry": { + "schemaVersion": 1, + "updatedAt": "2026-09-10T00:00:00.000Z", + "models": [ + { + "id": "test-model", + "name": "Test Model", + "status": "active", + "efforts": [ + "low", + "high" + ], + "defaultEffort": "high", + "supportsFastMode": true, + "routes": [ + { + "providerId": "openai", + "modelId": "test-model", + "agents": [ + "codex" + ] + } + ], + "contextWindow": 1000000, + "maxOutputTokens": 64000 + } + ] + } +} diff --git a/packages/model-providers/fixtures/published-catalog/valid-v2.json b/packages/model-providers/fixtures/published-catalog/valid-v2.json new file mode 100644 index 00000000000..b8b350fbb25 --- /dev/null +++ b/packages/model-providers/fixtures/published-catalog/valid-v2.json @@ -0,0 +1,72 @@ +{ + "version": "catalog-1", + "providers": [ + { + "id": "openai", + "name": "OpenAI", + "source": "builtin", + "agents": [ + "codex" + ], + "auth": { + "method": "oauth" + }, + "routing": { + "codex": { + "upstream": "https://api.example.com", + "authStrategy": "oauth-passthrough" + } + }, + "models": { + "codex": [] + } + } + ], + "presets": [ + { + "id": "example", + "name": "Example", + "runtimes": { + "codex": { + "baseUrl": "https://api.example.com/v1", + "models": [ + { + "contextWindow": 1000000, + "maxOutputTokens": 64000, + "id": "test-model", + "name": "Test Model" + } + ] + } + } + } + ], + "modelRegistry": { + "schemaVersion": 2, + "updatedAt": "2026-09-10T00:00:00.000Z", + "models": [ + { + "id": "test-model", + "name": "Test Model", + "status": "active", + "efforts": [ + "low", + "high" + ], + "defaultEffort": "high", + "supportsFastMode": true, + "routes": [ + { + "providerId": "openai", + "modelId": "test-model", + "agents": [ + "codex" + ] + } + ], + "contextWindow": 1000000, + "maxOutputTokens": 64000 + } + ] + } +} diff --git a/packages/model-providers/fixtures/published-catalog/valid-v3.json b/packages/model-providers/fixtures/published-catalog/valid-v3.json new file mode 100644 index 00000000000..b5028cdb888 --- /dev/null +++ b/packages/model-providers/fixtures/published-catalog/valid-v3.json @@ -0,0 +1,72 @@ +{ + "version": "catalog-1", + "providers": [ + { + "id": "openai", + "name": "OpenAI", + "source": "builtin", + "agents": [ + "codex" + ], + "auth": { + "method": "oauth" + }, + "routing": { + "codex": { + "upstream": "https://api.example.com", + "authStrategy": "oauth-passthrough" + } + }, + "models": { + "codex": [] + } + } + ], + "presets": [ + { + "id": "example", + "name": "Example", + "runtimes": { + "codex": { + "baseUrl": "https://api.example.com/v1", + "models": [ + { + "contextWindow": 1000000, + "maxOutputTokens": 64000, + "id": "test-model", + "name": "Test Model" + } + ] + } + } + } + ], + "modelRegistry": { + "schemaVersion": 3, + "updatedAt": "2026-09-10T00:00:00.000Z", + "models": [ + { + "id": "test-model", + "name": "Test Model", + "status": "active", + "efforts": [ + "low", + "high" + ], + "defaultEffort": "high", + "supportsFastMode": true, + "routes": [ + { + "providerId": "openai", + "modelId": "test-model", + "agents": [ + "codex" + ] + } + ], + "contextWindow": 1000000, + "maxOutputTokens": 64000 + } + ] + } +} diff --git a/packages/model-providers/fixtures/published-catalog/valid-v4.json b/packages/model-providers/fixtures/published-catalog/valid-v4.json new file mode 100644 index 00000000000..53d0ce3023e --- /dev/null +++ b/packages/model-providers/fixtures/published-catalog/valid-v4.json @@ -0,0 +1,81 @@ +{ + "version": "catalog-1", + "providers": [ + { + "id": "openai", + "name": "OpenAI", + "source": "builtin", + "agents": [ + "codex" + ], + "auth": { + "method": "oauth" + }, + "routing": { + "codex": { + "upstream": "https://api.example.com", + "authStrategy": "oauth-passthrough" + } + }, + "models": { + "codex": [] + } + } + ], + "presets": [ + { + "id": "example", + "name": "Example", + "runtimes": { + "codex": { + "baseUrl": "https://api.example.com/v1", + "models": [ + { + "contextWindow": 1000000, + "maxOutputTokens": 64000, + "id": "test-model", + "name": "Test Model" + } + ] + } + } + } + ], + "modelRegistry": { + "schemaVersion": 4, + "updatedAt": "2026-09-10T00:00:00.000Z", + "baseModels": [ + { + "id": "test-family", + "aliases": [], + "defaults": { + "contextWindow": 1000000, + "maxOutputTokens": 64000 + } + } + ], + "models": [ + { + "id": "test-model", + "modelRef": "test-family", + "name": "Test Model", + "status": "active", + "efforts": [ + "low", + "high" + ], + "defaultEffort": "high", + "supportsFastMode": true, + "routes": [ + { + "providerId": "openai", + "modelId": "test-model", + "agents": [ + "codex" + ] + } + ] + } + ] + } +} diff --git a/packages/model-providers/fixtures/published-catalog/valid-v5.json b/packages/model-providers/fixtures/published-catalog/valid-v5.json new file mode 100644 index 00000000000..4cbe02efb99 --- /dev/null +++ b/packages/model-providers/fixtures/published-catalog/valid-v5.json @@ -0,0 +1,103 @@ +{ + "version": "catalog-1", + "providers": [ + { + "id": "openai", + "name": "OpenAI", + "source": "builtin", + "agents": [ + "codex" + ], + "auth": { + "method": "oauth" + }, + "routing": { + "codex": { + "upstream": "https://api.example.com", + "authStrategy": "oauth-passthrough" + } + }, + "models": { + "codex": [] + } + } + ], + "presets": [ + { + "id": "example", + "name": "Example", + "runtimes": { + "codex": { + "baseUrl": "https://api.example.com/v1", + "models": [ + { + "id": "test-model", + "name": "Test Model", + "modelRef": "test-family" + } + ] + } + } + } + ], + "modelRegistry": { + "schemaVersion": 5, + "updatedAt": "2026-09-10T00:00:00.000Z", + "disabledPresetIds": [ + "withdrawn" + ], + "baseModels": [ + { + "id": "test-family", + "aliases": [], + "defaults": { + "contextWindow": 1000000, + "maxOutputTokens": 64000 + }, + "presentation": { + "en": { + "description": "Reviewed shared copy" + }, + "zh-CN": { + "description": "已编辑的共用简介" + } + } + } + ], + "models": [ + { + "id": "test-model", + "modelRef": "test-family", + "name": "Test Model", + "status": "active", + "efforts": [ + "low", + "high" + ], + "defaultEffort": "high", + "supportsFastMode": true, + "routes": [ + { + "providerId": "openai", + "modelId": "test-model", + "agents": [ + "codex" + ] + } + ], + "productDefaults": { + "visible": false, + "contextWindow": 272000, + "effort": "low", + "fast": true, + "preferredAgent": "codex", + "presentation": { + "en": { + "name": "Published name" + } + } + } + } + ] + } +} diff --git a/packages/model-providers/scripts/exportModelPresentationSeed.ts b/packages/model-providers/scripts/exportModelPresentationSeed.ts new file mode 100644 index 00000000000..34c0d8a4204 --- /dev/null +++ b/packages/model-providers/scripts/exportModelPresentationSeed.ts @@ -0,0 +1,69 @@ +/** One-time DB import seed; uses the reviewed translations already shipped to users. */ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { modelDescriptionKey } from "../src/legacyModelDescriptions.js"; +import { + MODEL_PRESENTATION_LOCALES, + type ModelPresentation, +} from "../src/modelPresentation.js"; +import { parseModelRegistry } from "../src/modelAccessValidator.js"; +const [registryPath, localesPath, output] = process.argv.slice(2); +if (!registryPath || !localesPath || !output) + throw new Error( + "Usage: tsx exportModelPresentationSeed.ts ", + ); +const parsed = parseModelRegistry( + JSON.parse(await readFile(registryPath, "utf8")), +); +if (!parsed.ok) throw new Error(parsed.error); +const registry = parsed.value; +const locales = Object.fromEntries( + await Promise.all( + MODEL_PRESENTATION_LOCALES.map(async (locale) => [ + locale, + JSON.parse( + await readFile(join(localesPath, locale, "common.json"), "utf8"), + ).modelDescriptions, + ]), + ), +); +const copy = (model: { + id: string; + mode?: string; + group?: string; +}): ModelPresentation => { + const key = modelDescriptionKey(model); + return key + ? Object.fromEntries( + MODEL_PRESENTATION_LOCALES.flatMap((locale) => + typeof locales[locale]?.[key] === "string" && locales[locale][key] + ? [[locale, { description: locales[locale][key] }]] + : [], + ), + ) + : {}; +}; +const baseModels = Object.fromEntries( + (registry.baseModels ?? []).flatMap((base) => { + const presentation = copy({ id: base.id, ...base.defaults }); + return Object.keys(presentation).length ? [[base.id, presentation]] : []; + }), +); +const models = Object.fromEntries( + registry.models.flatMap((model) => { + const presentation = copy(model); + return Object.keys(presentation).length && + JSON.stringify(presentation) !== + JSON.stringify(model.modelRef ? baseModels[model.modelRef] : undefined) + ? [[model.id, presentation]] + : []; + }), +); +await writeFile( + output, + JSON.stringify({ version: 1, baseModels, models }, null, 2) + "\n", + { flag: "wx" }, +); +process.stdout.write( + `Exported ${Object.keys(baseModels).length} shared and ${Object.keys(models).length} access-specific editorial copies.\n`, +); diff --git a/packages/model-providers/src/__tests__/modelCatalogPolicy.test.ts b/packages/model-providers/src/__tests__/modelCatalogPolicy.test.ts index 7813d9c27ff..b45a34e146b 100644 --- a/packages/model-providers/src/__tests__/modelCatalogPolicy.test.ts +++ b/packages/model-providers/src/__tests__/modelCatalogPolicy.test.ts @@ -1,48 +1,48 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from "vitest"; import { applyModelProductDefaults, resolveModelProductDefaults, validModelProductDefaults, -} from '../modelCatalogPolicy.js'; -import { buildUserProvider } from '../user-provider.js'; +} from "../modelCatalogPolicy.js"; +import { buildUserProvider } from "../user-provider.js"; import type { CatalogModel, CustomProviderConfig, ProviderPreset, -} from '../types.js'; -import type { ModelRegistry } from '../modelAccessBean.js'; +} from "../types.js"; +import type { ModelRegistry } from "../modelAccessBean.js"; -describe('published model defaults', () => { - it('distinguishes product identity, channel and engine despite duplicate legacy upstream IDs', () => { +describe("published model defaults", () => { + it("distinguishes product identity, channel and engine despite duplicate legacy upstream IDs", () => { const registry: ModelRegistry = { schemaVersion: 5, - updatedAt: '2026-09-10T00:00:00Z', + updatedAt: "2026-09-10T00:00:00Z", models: [ { - id: 'openai/gpt-test', - name: 'Test', + id: "openai/gpt-test", + name: "Test", routes: [ - { providerId: 'openai', modelId: 'gpt-test', agents: ['codex'] }, + { providerId: "openai", modelId: "gpt-test", agents: ["codex"] }, ], productDefaults: { contextWindow: 272000, fast: false, - perAgent: { pi: { effort: 'low' } }, + perAgent: { pi: { effort: "low" } }, }, }, { - id: 'openai/gpt-test[1m]', - name: 'Test', + id: "openai/gpt-test[1m]", + name: "Test", routes: [ - { providerId: 'openai', modelId: 'gpt-test', agents: ['codex'] }, + { providerId: "openai", modelId: "gpt-test", agents: ["codex"] }, ], productDefaults: { contextWindow: 1000000 }, }, { - id: 'xd/gpt-test', - name: 'Test', + id: "xd/gpt-test", + name: "Test", routes: [ - { providerId: 'xd', modelId: 'gpt-test', agents: ['codex'] }, + { providerId: "xd", modelId: "gpt-test", agents: ["codex"] }, ], productDefaults: { contextWindow: 200000 }, }, @@ -51,38 +51,38 @@ describe('published model defaults', () => { expect( resolveModelProductDefaults( registry, - 'openai', - 'chatgpt/gpt-test', - 'codex', + "openai", + "chatgpt/gpt-test", + "codex", )?.contextWindow, ).toBe(272000); expect( - resolveModelProductDefaults(registry, 'openai', 'gpt-test[1m]', 'codex') + resolveModelProductDefaults(registry, "openai", "gpt-test[1m]", "codex") ?.contextWindow, ).toBe(1000000); expect( - resolveModelProductDefaults(registry, 'openai', 'gpt-test', 'pi')?.effort, - ).toBe('low'); + resolveModelProductDefaults(registry, "openai", "gpt-test", "pi")?.effort, + ).toBe("low"); expect( - resolveModelProductDefaults(registry, 'xd', 'gpt-test', 'codex') + resolveModelProductDefaults(registry, "xd", "gpt-test", "codex") ?.contextWindow, ).toBe(200000); expect( - resolveModelProductDefaults(registry, 'my-api', 'gpt-test', 'codex'), + resolveModelProductDefaults(registry, "my-api", "gpt-test", "codex"), ).toBeUndefined(); }); - it('preserves maximum capacity, explicit false/null and never invents Fast support', () => { + it("preserves maximum capacity, explicit false/null and never invents Fast support", () => { const model: CatalogModel = { - id: 'test', - name: 'Test', + id: "test", + name: "Test", contextWindow: 1000000, - efforts: ['low', 'high'], - defaultEffort: 'high', + efforts: ["low", "high"], + defaultEffort: "high", }; const result = applyModelProductDefaults( model, { contextWindow: 272000, visible: false, effort: null, fast: true }, - 'catalog-7', + "catalog-7", ); expect(result).toMatchObject({ contextWindow: 272000, @@ -90,48 +90,48 @@ describe('published model defaults', () => { defaultEnabled: false, defaultEffort: null, defaultFast: false, - catalogDefaults: { revision: 'catalog-7' }, + catalogDefaults: { revision: "catalog-7" }, }); expect(model.contextWindow).toBe(1000000); expect(validModelProductDefaults({ fast: false, effort: null })).toBe(true); - expect(validModelProductDefaults({ fast: 'false' })).toBe(false); + expect(validModelProductDefaults({ fast: "false" })).toBe(false); expect( validModelProductDefaults({ - perAgent: { codex: { preferredAgent: 'pi' } }, + perAgent: { codex: { preferredAgent: "pi" } }, }), ).toBe(false); }); - it('updates a matching API template without replacing form edits or following a changed endpoint', () => { + it("updates a matching API template without replacing form edits or following a changed endpoint", () => { const config: CustomProviderConfig = { - id: 'personal', - name: 'Personal', + id: "personal", + name: "Personal", runtimes: { pi: { - catalogPresetId: 'official-api', - baseUrl: 'https://api.example/v1', - wireProtocol: 'openai-chat', - models: [{ id: 'm', name: 'M' }], + catalogPresetId: "official-api", + baseUrl: "https://api.example/v1", + wireProtocol: "openai-chat", + models: [{ id: "m", name: "M" }], }, }, }; const presets: ProviderPreset[] = [ { - id: 'official-api', - name: 'API', + id: "official-api", + name: "API", runtimes: { pi: { - baseUrl: 'https://api.example/v1', - wireProtocol: 'openai-chat', + baseUrl: "https://api.example/v1", + wireProtocol: "openai-chat", models: [ { - id: 'm', - name: 'M', + id: "m", + name: "M", contextWindow: 1000000, reasoning: true, - reasoningEfforts: ['low', 'high'], + reasoningEfforts: ["low", "high"], productDefaults: { contextWindow: 272000, - effort: 'low', + effort: "low", visible: false, }, }, @@ -143,19 +143,19 @@ describe('published model defaults', () => { const current = () => buildUserProvider(config, { presets, - catalogRevision: 'catalog-9', + catalogRevision: "catalog-9", modelRegistry: { schemaVersion: 5, - updatedAt: '2026-09-10T00:00:00Z', + updatedAt: "2026-09-10T00:00:00Z", models: [], }, }).models.pi![0]; expect(current()).toMatchObject({ contextWindow: 272000, contextWindowMax: 1000000, - defaultEffort: 'low', + defaultEffort: "low", defaultEnabled: false, - catalogDefaults: { revision: 'catalog-9' }, + catalogDefaults: { revision: "catalog-9" }, }); config.runtimes.pi!.models[0].contextWindow = 500000; config.runtimes.pi!.models[0].defaultEnabled = true; @@ -163,7 +163,133 @@ describe('published model defaults', () => { contextWindow: 500000, defaultEnabled: true, }); - config.runtimes.pi!.baseUrl = 'https://different.example/v1'; + config.runtimes.pi!.baseUrl = "https://different.example/v1"; expect(current().catalogDefaults).toBeUndefined(); }); }); + +it("inherits reviewed locale fields, keeps names separate from personal labels, and traces capability limits", async () => { + const { localizedModelPresentation } = + await import("../modelPresentation.js"); + const registry: ModelRegistry = { + schemaVersion: 5, + updatedAt: "2026-09-10T00:00:00Z", + baseModels: [ + { + id: "base", + aliases: [], + defaults: {}, + presentation: { + en: { name: "Base name", description: "Base description" }, + "zh-CN": { description: "共用简介" }, + }, + }, + ], + models: [ + { + id: "openai/test", + name: "Test", + modelRef: "base", + routes: [{ providerId: "openai", modelId: "test", agents: ["codex"] }], + productDefaults: { presentation: { en: { name: "Channel name" } } }, + }, + ], + }; + const policy = resolveModelProductDefaults( + registry, + "openai", + "test", + "codex", + ); + expect(localizedModelPresentation(policy?.presentation, "en-US")).toEqual({ + name: "Channel name", + description: "Base description", + }); + expect(localizedModelPresentation(policy?.presentation, "zh-Hans")).toEqual({ + name: "Channel name", + description: "共用简介", + }); + const model: CatalogModel = { + id: "test", + name: "Personal", + nameExplicit: true, + contextWindow: 100, + efforts: [], + defaultEffort: null, + }; + const effective = applyModelProductDefaults( + model, + { ...policy, fast: true, contextWindow: 200 }, + "catalog-1", + ); + expect(effective.presentation?.en?.name).toBeUndefined(); + expect(effective.presentation?.en?.description).toBe("Base description"); + expect(effective.fieldSources?.contextWindow.at(-1)).toMatchObject({ + source: "constraint", + value: 100, + }); + expect(effective.fieldSources?.defaultFast.at(-1)).toMatchObject({ + source: "constraint", + value: false, + }); +}); + +it("follows a referenced common model through a matching template without inheriting Pi reasoning", () => { + const registry: ModelRegistry = { + schemaVersion: 5, + updatedAt: "2026-09-10T00:00:00Z", + models: [], + baseModels: [ + { + id: "common", + aliases: [], + defaults: { + contextWindow: 900000, + efforts: ["high"], + defaultEffort: "high", + }, + presentation: { en: { description: "Shared editorial text" } }, + }, + ], + }; + const presets: ProviderPreset[] = [ + { + id: "api", + name: "API", + runtimes: { + pi: { + baseUrl: "https://api.example/v1", + wireProtocol: "openai-chat", + models: [{ id: "upstream", name: "Upstream", modelRef: "common" }], + }, + }, + }, + ]; + const config: CustomProviderConfig = { + id: "mine", + name: "Mine", + runtimes: { + pi: { + catalogPresetId: "api", + baseUrl: "https://api.example/v1", + wireProtocol: "openai-chat", + models: [{ id: "upstream", name: "Upstream", discoveredMetadata: {} }], + }, + }, + }; + const current = () => + buildUserProvider(config, { + modelRegistry: registry, + presets, + catalogRevision: "catalog-9", + }).models.pi![0]; + expect(current().contextWindow).toBe(900000); + expect(current().efforts).toEqual([]); + expect(current().presentation?.en?.description).toBe("Shared editorial text"); + registry.baseModels![0].defaults.contextWindow = 1000000; + expect(current().contextWindow).toBe(1000000); + config.runtimes.pi!.models[0].contextWindow = 12345; + expect(current().contextWindow).toBe(12345); + config.runtimes.pi!.baseUrl = "https://another.example/v1"; + expect(current().presentation).toBeUndefined(); +}); diff --git a/packages/model-providers/src/__tests__/modelMetadataLayers.test.ts b/packages/model-providers/src/__tests__/modelMetadataLayers.test.ts index e73a7fa0298..413025f8c22 100644 --- a/packages/model-providers/src/__tests__/modelMetadataLayers.test.ts +++ b/packages/model-providers/src/__tests__/modelMetadataLayers.test.ts @@ -420,3 +420,109 @@ it.each([true, false])( } }, ); + +it("records actual field precedence and verified correction evidence without putting traces in legacy metadata", async () => { + const { resolveModelMetadata, resolveModelMetadataWithSources } = + await import("../modelMetadataLayers.js"); + const registry = { + schemaVersion: 5 as const, + updatedAt: "2026-09-10T00:00:00Z", + baseModels: [{ id: "base", aliases: [], defaults: { contextWindow: 100 } }], + models: [ + { + id: "m", + name: "M", + modelRef: "base", + routes: [ + { + providerId: "openai", + modelId: "m", + agents: ["codex" as const], + forceOverrides: { contextWindow: 300 }, + overrideReason: "Measured upstream limit", + overrideVerifiedAt: "2026-09-10", + }, + ], + }, + ], + }; + const result = resolveModelMetadataWithSources( + registry, + "openai", + "m", + { contextWindow: 200 }, + { contextWindow: 400 }, + "codex", + ); + expect(result.contextWindow).toBe(400); + expect( + result.fieldSources.contextWindow.map((record) => record.source), + ).toEqual(["public", "discovery", "verified", "user"]); + expect(result.fieldSources.contextWindow[2]).toMatchObject({ + reason: "Measured upstream limit", + verifiedAt: "2026-09-10", + }); + expect( + resolveModelMetadata(registry, "openai", "m", { contextWindow: 200 }), + ).not.toHaveProperty("fieldSources"); +}); + +it("preserves personal names after a late metadata overlay without losing published descriptions", async () => { + const { applyModelMetadata } = await import("../modelMetadataLayers.js"); + const model = { + id: "model", + name: "Public", + efforts: [], + defaultEffort: null, + presentation: { en: { name: "Published", description: "Reviewed copy" } }, + } as unknown as import("../types.js").CatalogModel; + const result = applyModelMetadata(model, { + name: "Personal", + fieldSources: { name: [{ source: "user", value: "Personal" }] }, + }); + expect(result.name).toBe("Personal"); + expect(result.presentation).toEqual({ en: { description: "Reviewed copy" } }); +}); + +it("does not duplicate or relabel provenance when another engine consumes an assembled snapshot", async () => { + const { applyModelMetadata, resolveModelMetadataWithSources } = + await import("../modelMetadataLayers.js"); + let model = { + id: "model", + name: "Model", + contextWindow: 100, + efforts: [], + defaultEffort: null, + } as unknown as import("../types.js").CatalogModel; + const initial = resolveModelMetadataWithSources( + registry, + "supplier", + "model", + { contextWindow: 300 }, + undefined, + "codex", + undefined, + "discovery", + ); + model = applyModelMetadata(model, initial); + const expected = structuredClone(model.fieldSources); + for (let index = 0; index < 5; index++) + model = applyModelMetadata( + model, + resolveModelMetadataWithSources( + registry, + "supplier", + "model", + { contextWindow: 300 }, + undefined, + "codex", + undefined, + "fallback", + ), + ); + expect(model.fieldSources).toEqual(expected); + expect(model.fieldSources?.contextWindow?.at(-1)).toEqual({ + source: "discovery", + value: 300, + }); +}); diff --git a/packages/model-providers/src/__tests__/publishedCatalogContract.test.ts b/packages/model-providers/src/__tests__/publishedCatalogContract.test.ts new file mode 100644 index 00000000000..fd4aa7ca366 --- /dev/null +++ b/packages/model-providers/src/__tests__/publishedCatalogContract.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { parseCatalog } from "../catalog.js"; +const root = new URL("../../fixtures/published-catalog/", import.meta.url); +const read = (file: string) => + JSON.parse(readFileSync(new URL(file, root), "utf8")); +const manifest = read("manifest.json") as { + version: number; + cases: { file: string; valid: boolean; description: string }[]; +}; +describe("shared published catalog contract v1", () => { + for (const item of manifest.cases) + it(item.description, () => { + if (item.valid) expect(() => parseCatalog(read(item.file))).not.toThrow(); + else expect(() => parseCatalog(read(item.file))).toThrow(); + }); +}); diff --git a/packages/model-providers/src/catalog.ts b/packages/model-providers/src/catalog.ts index d3e6d2b027b..15d40d7c9e4 100644 --- a/packages/model-providers/src/catalog.ts +++ b/packages/model-providers/src/catalog.ts @@ -1,3 +1,4 @@ +import { validPresetModelReferences } from "./modelCatalogPolicy.js"; import { validModelProductDefaults } from "./modelCatalogPolicy.js"; /** * 目录运行时校验(parseCatalog)+ presets 清洗排序。 @@ -559,6 +560,7 @@ function isValidPreset(v: unknown): v is ProviderPreset { return false; } if (!hasValidPresetReasoningCapability(agent, mm)) return false; + if (mm.modelRef !== undefined && (typeof mm.modelRef !== "string" || !mm.modelRef || mm.modelRef.length > 256)) return false; if (mm.productDefaults !== undefined && !validModelProductDefaults(mm.productDefaults)) return false; } if (r.wireProtocol !== undefined && !isWireProtocol(r.wireProtocol)) return false; @@ -820,6 +822,7 @@ export function parseCatalog(input: string | unknown): Catalog { assert(registry.ok, registry.ok ? '' : registry.error); catalog.modelRegistry = registry.value; } + assert(validPresetModelReferences(presets,catalog.modelRegistry), "catalog preset modelRef is unresolved or requires Registry V5"); // Validate authored lists before projection so malformed input cannot be repaired or crash mapping. for (const provider of catalog.providers) { for (const field of ['imageModels', 'videoModels', 'audioModels', 'embeddingModels'] as const) diff --git a/packages/model-providers/src/classification.ts b/packages/model-providers/src/classification.ts index 7711238dfa5..85783adf97f 100644 --- a/packages/model-providers/src/classification.ts +++ b/packages/model-providers/src/classification.ts @@ -405,12 +405,18 @@ export function isModelSelectableForNewRoute( }, opts?: { userProvider?: boolean }, ): boolean { - return ( - model.disabled !== true && - model.status !== 'retired' && - model.availability !== 'requires_payment' && - isAgentSelectableModel(model, opts) - ); + return modelNewRouteBlockReason(model, opts) === null; +} + +/** The same reason is used by new-route admission and configuration diagnostics. Hidden is a display preference. */ +export function modelNewRouteBlockReason( + model: {id:string;group?:string;mode?:string;disabled?:boolean;status?:string;availability?:'available'|'requires_payment'}, + opts?: {userProvider?:boolean}, +): 'disabled' | 'retired' | 'requires_payment' | 'non_chat' | null { + if (model.disabled === true) return 'disabled'; + if (model.status === 'retired') return 'retired'; + if (model.availability === 'requires_payment') return 'requires_payment'; + return isAgentSelectableModel(model,opts) ? null : 'non_chat'; } /** groupModelsForDisplay 的最小模型形状(id + 可选 group / mode / sortOrder)。 */ diff --git a/packages/model-providers/src/index.ts b/packages/model-providers/src/index.ts index d811c68579d..75cba1bf45f 100644 --- a/packages/model-providers/src/index.ts +++ b/packages/model-providers/src/index.ts @@ -293,3 +293,14 @@ export { PROVIDER_MEDIA_FIELDS, providerMediaField, projectProviderMediaModels } export { applyModelProductDefaults, resolveModelProductDefaults } from "./modelCatalogPolicy.js"; export type { ModelProductDefaults } from "./modelCatalogPolicy.js"; + +export { localizedModelPresentation, mergeModelPresentation, MODEL_PRESENTATION_LOCALES } from "./modelPresentation.js"; +export type { ModelPresentation } from "./modelPresentation.js"; + +export { resolveModelMetadataWithSources } from "./modelMetadataLayers.js"; +export { appendModelFieldSources } from "./modelFieldSources.js"; +export type { ModelFieldSources, ModelFieldSource } from "./modelFieldSources.js"; + +export { modelNewRouteBlockReason } from "./classification.js"; + +export { modelDescriptionKey } from "./legacyModelDescriptions.js"; diff --git a/packages/model-providers/src/legacyModelDescriptions.ts b/packages/model-providers/src/legacyModelDescriptions.ts new file mode 100644 index 00000000000..16d252b5c87 --- /dev/null +++ b/packages/model-providers/src/legacyModelDescriptions.ts @@ -0,0 +1,69 @@ +import { classifyModel } from "./classification.js"; +/** Editorial display copy only: never use a description to infer routing or capabilities. + * Families intentionally accept future versions; new families remain visible without a blurb. + * Raw catalog descriptions are retained in the catalog, but never used as UI fallback. + */ +const families: ReadonlyArray = [ + [/^gpt-.*\[1m\]$/, "longContext"], + [/^gpt-.*-cyber(?:-|$)/, "security"], + [/^gpt-.*-astra(?:-|$)/, "research"], + [/^gpt-.*(?:-luna|-mini|-nano|-spark)(?:-|$)/, "quickCoding"], + [/^(?:gpt-.*-pro(?:-|$)|o[134](?:-|$))/, "reasoning"], + [/^gpt-/, "coding"], + [/^claude-fable-/, "sustainedWork"], + [/^claude-opus-/, "complexWork"], + [/^claude-sonnet-/, "everydayWork"], + [/^claude-haiku-/, "quickAnswers"], + [/^claude-/, "everydayWork"], + [/^gemini-.*flash-lite/, "shortTasks"], + [/^gemini-.*flash/, "everydayWork"], + [/^gemini-/, "research"], + [/^deepseek-.*(?:-vision|-vl)/, "everydayWork"], + [/^deepseek-.*-flash(?:-|$)/, "everydayWork"], + [/^deepseek-/, "reasoning"], + [/^qwen(?:\d|[-/])/, "writingAndCoding"], + [/^kimi-.*-code(?:-|$)/, "coding"], + [/^kimi-/, "readingAndWriting"], + [/^glm-/, "writingAndCoding"], + [/^(?:doubao-)?seed-\d/, "everydayWork"], + [/^(?:hy\d|hunyuan(?:-|\d))/, "readingAndWriting"], + [/^minimax-/, "coding"], + [/^muse-spark-/, "everydayWork"], + [/^grok-(?:build|code)-/, "coding"], + [/^grok-/, "reasoning"], +]; + +const categoryKeys: Readonly> = { + image: "image", + video: "video", + tts: "speech", + stt: "transcription", + realtime: "realtime", + embedding: "embedding", + compression: "compression", +}; + +export function modelDescriptionKey(model: { + id: string; + group?: string; + mode?: string; +}): string | undefined { + // A vendor grouping like "gpt" must not turn GPT Image into a coding model. + const inferredCategory = classifyModel({ id: model.id, mode: model.mode }); + const category = + model.mode !== undefined || Object.hasOwn(categoryKeys, inferredCategory) + ? inferredCategory + : classifyModel(model); + if (Object.hasOwn(categoryKeys, category)) return categoryKeys[category]; + // An explicitly non-chat/unknown mode must not inherit a chat-family blurb. + if (category === "other" || category === "audio") return undefined; + let id = model.id.toLowerCase().split("/").at(-1) ?? ""; + // Registry internal XD IDs have flattened route prefixes. Wire IDs usually don't. + if (model.id.startsWith("xd/")) { + id = id.replace( + /^(?:codex|z-ai|moonshotai|deepseek|tencent)-(?=gpt-|glm-|kimi-|deepseek-|hy\d)/, + "", + ); + } + return families.find(([pattern]) => pattern.test(id))?.[1]; +} diff --git a/packages/model-providers/src/modelAccessValidator.ts b/packages/model-providers/src/modelAccessValidator.ts index 1f2a5f171f0..52dc6790d67 100644 --- a/packages/model-providers/src/modelAccessValidator.ts +++ b/packages/model-providers/src/modelAccessValidator.ts @@ -1,3 +1,4 @@ +import { validModelPresentation } from "./modelPresentation.js"; import { validModelProductDefaults } from "./modelCatalogPolicy.js"; import { validModelMetadata, @@ -1312,7 +1313,8 @@ export function parseModelRegistry( for (const base of value.baseModels) { if ( !isPlainObject(base) || - unknownFieldError(base, ["id", "aliases", "defaults"], "baseModel") || + unknownFieldError(base, ["id", "aliases", "defaults", ...(value.schemaVersion === 5 ? ["presentation"] : [])], "baseModel") || + (base.presentation !== undefined && !validModelPresentation(base.presentation)) || typeof base.id !== "string" || !base.id || base.id.length > 256 || diff --git a/packages/model-providers/src/modelCatalogPolicy.ts b/packages/model-providers/src/modelCatalogPolicy.ts index 5f346037d19..8bc21b7ffd6 100644 --- a/packages/model-providers/src/modelCatalogPolicy.ts +++ b/packages/model-providers/src/modelCatalogPolicy.ts @@ -1,13 +1,20 @@ -import type { CatalogModel } from './types.js'; -import { clampEffortToSupported } from './effortResolution.js'; +import { appendModelFieldSources } from "./modelFieldSources.js"; +import { + validModelPresentation, + mergeModelPresentation, + type ModelPresentation, +} from "./modelPresentation.js"; +import type { CatalogModel } from "./types.js"; +import { clampEffortToSupported } from "./effortResolution.js"; import type { ModelAgent, ModelEffort, ModelRegistry, -} from './modelAccessBean.js'; +} from "./modelAccessBean.js"; /** Product choices, independent of vendor capabilities and user overrides. */ export interface ModelProductDefaults { + presentation?: ModelPresentation; visible?: boolean; preferredAgent?: ModelAgent; contextWindow?: number; @@ -16,7 +23,7 @@ export interface ModelProductDefaults { perAgent?: Partial< Record< ModelAgent, - Omit + Omit > >; } @@ -25,21 +32,21 @@ export function validModelProductDefaults( value: unknown, nested = false, ): value is ModelProductDefaults { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + if (!value || typeof value !== "object" || Array.isArray(value)) return false; const v = value as Record; - const agents = ['claude-code', 'codex', 'pi']; - const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']; + const agents = ["claude-code", "codex", "pi"]; + const efforts = ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]; const fields = [ - 'visible', - 'contextWindow', - 'effort', - 'fast', - ...(nested ? [] : ['preferredAgent', 'perAgent']), + "visible", + "contextWindow", + "effort", + "fast", + ...(nested ? [] : ["preferredAgent", "perAgent", "presentation"]), ]; if (Object.keys(v).some((key) => !fields.includes(key))) return false; if ( - ['visible', 'fast'].some( - (key) => v[key] !== undefined && typeof v[key] !== 'boolean', + ["visible", "fast"].some( + (key) => v[key] !== undefined && typeof v[key] !== "boolean", ) ) return false; @@ -59,10 +66,12 @@ export function validModelProductDefaults( !agents.includes(v.preferredAgent as string) ) return false; + if (v.presentation !== undefined && !validModelPresentation(v.presentation)) + return false; if (v.perAgent !== undefined) { if ( !v.perAgent || - typeof v.perAgent !== 'object' || + typeof v.perAgent !== "object" || Array.isArray(v.perAgent) ) return false; @@ -85,9 +94,9 @@ export function resolveModelProductDefaults( ): ModelProductDefaults | undefined { if (registry?.schemaVersion !== 5) return undefined; const id = - providerId === 'openai' - ? modelId.replace(/^chatgpt\//, '').replace(/\[1m\]$/, '') - : providerId === 'xai' && !modelId.startsWith('xai/') + providerId === "openai" + ? modelId.replace(/^chatgpt\//, "").replace(/\[1m\]$/, "") + : providerId === "xai" && !modelId.startsWith("xai/") ? `xai/${modelId}` : modelId; const entries = registry.models.filter((entry) => @@ -99,10 +108,18 @@ export function resolveModelProductDefaults( const exact = entries.find( (entry) => - entry.id === `${providerId}/${modelId.replace(/^chatgpt\//, '')}`, + entry.id === `${providerId}/${modelId.replace(/^chatgpt\//, "")}`, ) ?? entries.find((entry) => entry.id === `${providerId}/${id}`); const entry = exact ?? (entries.length === 1 ? entries[0] : undefined); - const defaults = entry?.productDefaults; + const presentation = mergeModelPresentation( + registry.baseModels?.find((base) => base.id === entry?.modelRef) + ?.presentation, + entry?.productDefaults?.presentation, + ); + const defaults = + entry?.productDefaults || presentation + ? { ...entry?.productDefaults, ...(presentation ? { presentation } : {}) } + : undefined; return defaults ? { ...defaults, ...defaults.perAgent?.[agent], perAgent: undefined } : undefined; @@ -116,8 +133,20 @@ export function applyModelProductDefaults( ): CatalogModel { if (!defaults) return model; const capacity = model.contextWindowMax ?? model.contextWindow; - return { + const result: CatalogModel = { ...model, + fieldSources: appendModelFieldSources( + model.fieldSources, + { + contextWindow: defaults.contextWindow, + defaultEffort: defaults.effort, + defaultFast: defaults.fast, + defaultEnabled: defaults.visible, + preferredAgent: defaults.preferredAgent, + presentation: defaults.presentation, + }, + "product", + ), ...(defaults.contextWindow !== undefined ? { contextWindow: Math.min(defaults.contextWindow, capacity), @@ -135,7 +164,7 @@ export function applyModelProductDefaults( : (clampEffortToSupported( defaults.effort, model.efforts, - ) as CatalogModel['defaultEffort']), + ) as CatalogModel["defaultEffort"]), } : {}), ...(defaults.fast !== undefined @@ -144,6 +173,21 @@ export function applyModelProductDefaults( ...(defaults.preferredAgent !== undefined ? { preferredAgent: defaults.preferredAgent } : {}), + ...(defaults.presentation + ? { + presentation: + model.nameExplicit || model.userModelConfig?.nameExplicit + ? Object.fromEntries( + Object.entries(defaults.presentation).map( + ([locale, copy]) => [ + locale, + { description: copy.description }, + ], + ), + ) + : defaults.presentation, + } + : {}), catalogDefaults: { revision, contextWindow: defaults.contextWindow, @@ -152,4 +196,42 @@ export function applyModelProductDefaults( visible: defaults.visible, }, }; + const constrained = { + ...(defaults.contextWindow !== undefined && + result.contextWindow !== defaults.contextWindow + ? { contextWindow: result.contextWindow } + : {}), + ...(defaults.effort !== undefined && + result.defaultEffort !== defaults.effort + ? { defaultEffort: result.defaultEffort } + : {}), + ...(defaults.fast !== undefined && result.defaultFast !== defaults.fast + ? { defaultFast: result.defaultFast } + : {}), + }; + result.fieldSources = appendModelFieldSources( + result.fieldSources, + constrained, + "constraint", + ); + return result; +} + +/** References are a V5 feature; a dangling public template must never silently borrow unrelated facts. */ +export function validPresetModelReferences( + presets: unknown, + registry: ModelRegistry | undefined, +): boolean { + for (const preset of (presets ?? []) as { + runtimes: Record; + }[]) + for (const runtime of Object.values(preset.runtimes)) + for (const model of runtime.models) + if ( + model.modelRef !== undefined && + (registry?.schemaVersion !== 5 || + !registry.baseModels?.some((base) => base.id === model.modelRef)) + ) + return false; + return true; } diff --git a/packages/model-providers/src/modelFieldSources.ts b/packages/model-providers/src/modelFieldSources.ts new file mode 100644 index 00000000000..126e7aeb423 --- /dev/null +++ b/packages/model-providers/src/modelFieldSources.ts @@ -0,0 +1,38 @@ +/** Only public model fields are recorded; credentials and account data never enter this trace. */ +export type ModelFieldSource = + | "public" + | "provider" + | "access" + | "engine" + | "discovery" + | "verified" + | "user" + | "product" + | "fallback" + | "constraint"; +export type ModelFieldRecord = { + source: ModelFieldSource; + value: unknown; + reason?: string; + verifiedAt?: string; +}; +export type ModelFieldSources = Record; +export function appendModelFieldSources( + previous: ModelFieldSources | undefined, + values: object, + source: ModelFieldSource, + details?: Pick, +): ModelFieldSources { + const result = { ...previous }; + for (const [field, value] of Object.entries(values)) + if (value !== undefined) { + const record = { source, value, ...details }; + const history = result[field] ?? []; + // Reassembly of the same snapshot must not grow trace history without bound. + result[field] = [ + ...history.filter((item) => item.source !== source), + record, + ]; + } + return result; +} diff --git a/packages/model-providers/src/modelMetadataLayers.ts b/packages/model-providers/src/modelMetadataLayers.ts index 96facf76a29..dc6709cbb0a 100644 --- a/packages/model-providers/src/modelMetadataLayers.ts +++ b/packages/model-providers/src/modelMetadataLayers.ts @@ -1,3 +1,9 @@ +import { + appendModelFieldSources, + type ModelFieldSources, + type ModelFieldSource, +} from "./modelFieldSources.js"; +import type { ModelPresentation } from "./modelPresentation.js"; import type { CatalogModel } from "./types.js"; import type { ModelRegistry, @@ -22,6 +28,7 @@ export interface ModelMetadata { supportsImageInput?: boolean; } export interface BaseModel { + presentation?: ModelPresentation; id: string; aliases: string[]; defaults: ModelMetadata; @@ -145,7 +152,7 @@ export function registryEntryDefaults( : undefined, ); } -export function resolveModelMetadata( +function resolveMetadata( registry: ModelRegistry | undefined, providerId: string, modelId: string, @@ -153,7 +160,9 @@ export function resolveModelMetadata( user?: ModelMetadata, agent?: string, providerDefaults?: ModelMetadata, -): ModelMetadata { + liveSource: ModelFieldSource = "discovery", + baseModelRef?: string, +): { metadata: ModelMetadata; fieldSources: ModelFieldSources } { const ids = [modelId]; if (providerId === "openai" && modelId.startsWith("chatgpt/")) ids.push(modelId.slice(8)); @@ -176,8 +185,19 @@ export function resolveModelMetadata( .map((route) => ({ entry, route })), ) ?? [], )[0]; - const defaults = - matched && registry + const defaults = baseModelRef + ? mergeModelMetadata( + presetBaseMetadata(registry, baseModelRef, agent ?? ""), + providerDefaults, + matched?.entry, + matched?.route.defaults, + agent + ? matched?.entry.perAgent?.[ + agent as keyof NonNullable + ] + : undefined, + ) + : matched && registry ? registryEntryDefaults( registry, matched.entry, @@ -195,6 +215,43 @@ export function resolveModelMetadata( matched?.route.forceOverrides, user, ); + let fieldSources: ModelFieldSources = {}; + const add = ( + value: ModelMetadata | undefined, + source: ModelFieldSource, + details?: { reason?: string; verifiedAt?: string }, + ) => { + fieldSources = appendModelFieldSources( + fieldSources, + pickModelMetadata(value), + source, + details, + ); + }; + add( + baseModelRef + ? presetBaseMetadata(registry, baseModelRef, agent ?? "") + : findBaseModel(registry, matched?.entry.modelRef ?? modelId)?.defaults, + "public", + ); + add(providerDefaults, "provider"); + add(matched?.entry, "access"); + add(matched?.route.defaults, "access"); + add( + agent + ? matched?.entry.perAgent?.[ + agent as keyof NonNullable + ] + : undefined, + "engine", + ); + add(live, liveSource); + add(matched?.route.forceOverrides, "verified", { + reason: matched?.route.overrideReason, + verifiedAt: matched?.route.overrideVerifiedAt, + }); + add(user, "user"); + const requestedEffort = result.defaultEffort; if (result.efforts?.length === 0) result.defaultEffort = null; else if ( result.defaultEffort != null && @@ -215,7 +272,20 @@ export function resolveModelMetadata( supported[0] ?? null; } - return result; + if (result.defaultEffort !== requestedEffort) + add({ defaultEffort: result.defaultEffort }, "constraint"); + return { metadata: result, fieldSources }; +} +export function resolveModelMetadata( + ...args: Parameters +): ModelMetadata { + return resolveMetadata(...args).metadata; +} +export function resolveModelMetadataWithSources( + ...args: Parameters +): ModelMetadata & { fieldSources: ModelFieldSources } { + const result = resolveMetadata(...args); + return { ...result.metadata, fieldSources: result.fieldSources }; } /** Legacy consumers receive fully expanded entries without losing saved IDs or routes. */ @@ -348,17 +418,49 @@ export function catalogModelMetadata( } export function applyModelMetadata( model: CatalogModel, - metadata: ModelMetadata, + metadata: ModelMetadata & { fieldSources?: ModelFieldSources }, ): CatalogModel { - const { maxOutputTokens, ...fields } = metadata; + const { maxOutputTokens, fieldSources, ...fields } = metadata; const result = { ...model, ...fields, + ...(fieldSources + ? { + fieldSources: { + ...model.fieldSources, + ...Object.fromEntries( + Object.entries(fieldSources).map(([field, records]) => [ + field, + records + .flatMap((record) => + record.source === "fallback" && model.fieldSources?.[field] + ? model.fieldSources[field] + : [record], + ) + .filter( + (record, index, history) => + history.findLastIndex( + (item) => item.source === record.source, + ) === index, + ), + ]), + ), + }, + } + : {}), ...(metadata.contextWindow !== undefined ? { contextWindowVerified: true } : {}), ...(maxOutputTokens !== undefined ? { maxOutput: maxOutputTokens } : {}), }; + if (fieldSources?.name?.at(-1)?.source === "user" && result.presentation) { + result.presentation = Object.fromEntries( + Object.entries(result.presentation).map(([locale, copy]) => [ + locale, + { description: copy.description }, + ]), + ); + } if ( result.efforts.length === 0 || (result.defaultEffort != null && @@ -430,3 +532,20 @@ export function runtimeUserModelMetadata( : {}), }); } + +export function presetBaseMetadata( + registry: ModelRegistry | undefined, + reference: string | undefined, + agent: string, +): ModelMetadata | undefined { + if (!reference) return undefined; + const value = findBaseModel(registry, reference)?.defaults; + if (!value || agent !== "pi") return value; + const { + efforts: _efforts, + defaultEffort: _defaultEffort, + supportsFastMode: _fast, + ...facts + } = value; + return facts; +} diff --git a/packages/model-providers/src/modelPresentation.ts b/packages/model-providers/src/modelPresentation.ts new file mode 100644 index 00000000000..1d82c46c175 --- /dev/null +++ b/packages/model-providers/src/modelPresentation.ts @@ -0,0 +1,56 @@ +/** Reviewed product copy, independent of vendor prose and model capabilities. */ +export const MODEL_PRESENTATION_LOCALES = [ + "en", + "zh-CN", + "zh-TW", + "ja", + "ko", +] as const; +export type ModelPresentation = Partial< + Record< + (typeof MODEL_PRESENTATION_LOCALES)[number], + { name?: string; description?: string } + > +>; +export function validModelPresentation( + value: unknown, +): value is ModelPresentation { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return Object.entries(value).every( + ([locale, copy]) => + (MODEL_PRESENTATION_LOCALES as readonly string[]).includes(locale) && + copy && + typeof copy === "object" && + !Array.isArray(copy) && + Object.entries(copy).every( + ([field, text]) => + (field === "name" || field === "description") && + typeof text === "string" && + text.trim().length > 0 && + text.length <= (field === "name" ? 256 : 2000), + ), + ); +} +export function mergeModelPresentation( + ...layers: (ModelPresentation | undefined)[] +): ModelPresentation | undefined { + const result: ModelPresentation = {}; + for (const layer of layers) + for (const locale of MODEL_PRESENTATION_LOCALES) + if (layer?.[locale]) + result[locale] = { ...result[locale], ...layer[locale] }; + return Object.keys(result).length ? result : undefined; +} +export function localizedModelPresentation( + value: ModelPresentation | undefined, + language: string, +) { + const normalized = language.toLowerCase(); + const locale = normalized.startsWith("zh") + ? /tw|hk|hant/.test(normalized) + ? "zh-TW" + : "zh-CN" + : normalized.split("-")[0]; + const selected = value?.[locale as keyof ModelPresentation]; + return selected || value?.en ? { ...value?.en, ...selected } : undefined; +} diff --git a/packages/model-providers/src/sections.ts b/packages/model-providers/src/sections.ts index f5a8a4affab..54d4e5711e0 100644 --- a/packages/model-providers/src/sections.ts +++ b/packages/model-providers/src/sections.ts @@ -30,6 +30,9 @@ export function isModelVisible( /** 「按供应商分段」里每行模型的展示形状(从 catalog 派生,字段够渲染 + effort 配置用)。 */ export interface SectionModel { + presentation?: CatalogModel["presentation"]; + fieldSources?: CatalogModel["fieldSources"]; + catalogDefaults?: CatalogModel["catalogDefaults"]; defaultFast?: boolean; id: string; displayName: string; @@ -177,6 +180,9 @@ export function buildProviderSections(args: { models: models.map((m) => { const sm: SectionModel = { id: m.id, + presentation: m.presentation, + fieldSources: m.fieldSources, + catalogDefaults: m.catalogDefaults, displayName: m.name, efforts: m.efforts, ...(m.displayEfforts ? { displayEfforts: m.displayEfforts } : {}), diff --git a/packages/model-providers/src/source.ts b/packages/model-providers/src/source.ts index 54a212689e8..81cc19a17ec 100644 --- a/packages/model-providers/src/source.ts +++ b/packages/model-providers/src/source.ts @@ -124,7 +124,7 @@ export function resolveCatalogUrl(cfg: CatalogSourceConfig): string | null { try { const url = new URL(explicit); if (url.pathname.endsWith("/api/model-catalog/catalog")) { - url.searchParams.set("registrySchemaVersion", "4"); + url.searchParams.set("registrySchemaVersion", "5"); return url.toString(); } } catch { diff --git a/packages/model-providers/src/types.ts b/packages/model-providers/src/types.ts index 21bd18b5ed3..620156e3aaa 100644 --- a/packages/model-providers/src/types.ts +++ b/packages/model-providers/src/types.ts @@ -273,6 +273,8 @@ export interface ModelCost { * 跨 provider(如 gpt-5.5 同时由 openai 与 xd 提供)则必须元数据一致(见 catalog.ts 校验)。 */ export interface CatalogModel { + fieldSources?: import("./modelFieldSources.js").ModelFieldSources; + presentation?: import("./modelPresentation.js").ModelPresentation; /** Published product defaults; optional on older hosts. Personal overrides remain separate. */ defaultFast?: boolean; preferredAgent?: AgentKind; @@ -583,6 +585,8 @@ export interface ProviderRuntimeModelConfig extends Pick< /** 同一 runtime 内该模型的上游覆盖;缺省使用 runtime 级路由。 */ route?: ProviderModelRouteConfig; contextWindow?: number; + /** Shared facts reference on a published template; does not grant routing or credentials. */ + modelRef?: string; /** Published template defaults; consumed only from a matching current preset, never a user snapshot. */ productDefaults?: import("./modelCatalogPolicy.js").ModelProductDefaults; /** 模型未被用户显式开关时的可见性;缺省保持历史行为(默认可见)。 */ diff --git a/packages/model-providers/src/unifiedSelection.ts b/packages/model-providers/src/unifiedSelection.ts index d755b76c6ad..4e1576fe7f1 100644 --- a/packages/model-providers/src/unifiedSelection.ts +++ b/packages/model-providers/src/unifiedSelection.ts @@ -527,6 +527,7 @@ export function resolveAgentCapability( /** 联合列表的一行:一个逻辑模型 (provider, 归一化 modelId),横跨它能用的所有引擎。 */ export interface UnifiedModelEntry { + presentation?: CatalogModel["presentation"]; providerId: string; /** ★**归一化 id**(行的稳定身份 / override / 收藏 key)。发请求请用 `capabilities[agent].wireModelId`。 */ modelId: string; @@ -816,6 +817,7 @@ export function unifiedModelEntries(opts: UnifiedModelEntriesOptions): UnifiedMo modelId: draft.keyModelId, displayName: display?.name ?? draft.keyModelId, ...(display?.description !== undefined ? { description: display.description } : {}), + ...(display?.presentation ? { presentation: display.presentation } : {}), ...(display?.group !== undefined ? { group: display.group } : {}), ...(display?.sortOrder !== undefined ? { sortOrder: display.sortOrder } : {}), ...(display?.icon !== undefined ? { icon: display.icon } : {}), diff --git a/packages/model-providers/src/user-provider.ts b/packages/model-providers/src/user-provider.ts index ece550c2652..fd883accc8d 100644 --- a/packages/model-providers/src/user-provider.ts +++ b/packages/model-providers/src/user-provider.ts @@ -1,8 +1,10 @@ +import { mergeModelPresentation } from "./modelPresentation.js"; import { applyModelProductDefaults, resolveModelProductDefaults } from "./modelCatalogPolicy.js"; import { providerMediaField } from "./providerMediaModels.js"; import { expandedRegistryEntries, - resolveModelMetadata, + findBaseModel, + resolveModelMetadataWithSources as resolveModelMetadata, applyModelMetadata, pickModelMetadata, runtimeUserModelMetadata, @@ -286,6 +288,7 @@ function toCatalogModel( agent: AgentKind, modelRegistry: ModelRegistry | null | undefined, providerDefaults?: ModelMetadata, + baseModelRef?: string, ): CatalogModel { // 显式 runtime 能力优先:reasoning:true 才导出 efforts;false = 明确无思考档。 // 字段缺省才走历史 fallback(Pi 空档 / 其它自定义 Provider 的 CUSTOM_EFFORTS)。 @@ -352,6 +355,8 @@ function toCatalogModel( pickModelMetadata(user), agent, providerDefaults, + "discovery", + baseModelRef, ) : pickModelMetadata(user); return applyModelMetadata(model, resolved); @@ -487,9 +492,17 @@ export function buildUserProvider( agent, options.modelRegistry, defaults, + followsPreset && sameRoute ? presetModel?.modelRef : undefined, ); if (followsPreset && sameRoute && presetModel) { - const policy = presetModel.productDefaults ?? resolveModelProductDefaults(options.modelRegistry ?? undefined, preset!.id, m.id, agent); + const inherited = resolveModelProductDefaults(options.modelRegistry ?? undefined, preset!.id, m.id, agent); + const basePresentation = findBaseModel(options.modelRegistry ?? undefined, presetModel.modelRef ?? m.id)?.presentation; + const presentation = mergeModelPresentation(basePresentation,inherited?.presentation,presetModel.productDefaults?.presentation); + const policy = inherited || presetModel.productDefaults || presentation ? { + ...inherited,...presetModel.productDefaults, + perAgent:{...inherited?.perAgent,...presetModel.productDefaults?.perAgent}, + ...(presentation ? {presentation} : {}), + } : undefined; if (policy) { const effective = { ...policy, ...policy.perAgent?.[agent] }; // Explicit form edits are personal choices, not published defaults. diff --git a/scripts/check-model-catalog-contract.mjs b/scripts/check-model-catalog-contract.mjs new file mode 100644 index 00000000000..1f5bef3b460 --- /dev/null +++ b/scripts/check-model-catalog-contract.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { createHash } from "node:crypto"; +const peer = process.argv[2]; +if (!peer) + throw new Error( + "Usage: node scripts/check-model-catalog-contract.mjs /absolute/path/to/peer-worktree", + ); +const locate = (root) => { + const dir = [ + "packages/model-providers/fixtures/published-catalog", + "model-access-server/fixtures/published-catalog", + ] + .map((path) => join(root, path)) + .find(existsSync); + if (!dir) throw new Error(`No catalog fixtures under ${root}`); + return dir; +}; +const local = locate(process.cwd()), + other = locate(resolve(peer)); +const files = readdirSync(local).sort(); +if (JSON.stringify(files) !== JSON.stringify(readdirSync(other).sort())) + throw new Error("Fixture file sets differ"); +for (const file of files) { + const hash = (root) => + createHash("sha256") + .update(readFileSync(join(root, file))) + .digest("hex"); + if (hash(local) !== hash(other)) + throw new Error(`Contract fixture mismatch: ${file}`); +} +process.stdout.write( + `${files.length} identical contract files; run each repository's contract tests independently.\n`, +);