diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 8f8c72e8f2..57a2c31449 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1502,7 +1502,11 @@ export const ar = defineLocale({ message: count => `سيتم تخطي ${count} من المهام المجدولة حتى تراجع إعدادات النموذج الخاصة بها.`, detailMore: (names, remaining) => `${names} و${remaining} أخرى`, review: 'مراجعة المهام المجدولة', - saveFailed: 'لم يحفظ Hermes تغيير النموذج هذا.' + saveFailed: 'لم يحفظ Hermes تغيير النموذج هذا.', + confirmTitle: 'تحذير اختيار النموذج', + confirmDetail: 'أكّد فقط إذا كنت تقبل هذه المقايضة.', + confirmAction: 'تأكيد', + declined: 'أُلغي تغيير النموذج — رفضت تحذير طبقة تدريب البيانات.' }, search: 'بحث', loading: 'جار التحميل...', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 198d66a2c0..71ed354c07 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1944,7 +1944,11 @@ export const en: Translations = { `${count} scheduled ${count === 1 ? 'job' : 'jobs'} will be skipped until you review their model settings.`, detailMore: (names, remaining) => `${names} and ${remaining} more`, review: 'Review scheduled jobs', - saveFailed: 'Hermes did not save that model change.' + saveFailed: 'Hermes did not save that model change.', + confirmTitle: 'Model Selection Warning', + confirmDetail: 'Confirm only if you accept this trade-off.', + confirmAction: 'Confirm', + declined: 'Model change cancelled — you declined the data-training tier warning.' }, search: 'Search cron jobs...', loading: 'Loading cron jobs...', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index f761310876..9dede3d19e 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1646,7 +1646,11 @@ export const ja = defineLocale({ message: count => `モデル設定を確認するまで、${count} 件のスケジュール済みジョブがスキップされます。`, detailMore: (names, remaining) => `${names}、ほか ${remaining} 件`, review: 'スケジュール済みジョブを確認', - saveFailed: 'Hermes はモデルの変更を保存しませんでした。' + saveFailed: 'Hermes はモデルの変更を保存しませんでした。', + confirmTitle: 'モデル選択の警告', + confirmDetail: 'このトレードオフを受け入れる場合のみ確認してください。', + confirmAction: '確認', + declined: 'モデル変更をキャンセルしました — データ学習ティアの警告を拒否しました。' }, search: 'Cron ジョブを検索...', loading: 'Cron ジョブを読み込み中...', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 47ab2df7f8..fe962a860e 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1657,6 +1657,10 @@ export interface Translations { detailMore: (names: string, remaining: number) => string review: string saveFailed: string + confirmTitle: string + confirmDetail: string + confirmAction: string + declined: string } search: string loading: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index e06f28013d..7601377089 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1585,7 +1585,11 @@ export const zhHant = defineLocale({ message: count => `在您檢查模型設定之前,${count} 個排程工作將被略過。`, detailMore: (names, remaining) => `${names},以及另外 ${remaining} 個`, review: '檢查排程工作', - saveFailed: 'Hermes 未儲存該模型變更。' + saveFailed: 'Hermes 未儲存該模型變更。', + confirmTitle: '模型選擇警告', + confirmDetail: '僅在你接受此權衡時確認。', + confirmAction: '確認', + declined: '已取消模型變更 — 你拒絕了資料訓練層級警告。' }, search: '搜尋排程工作…', loading: '正在載入排程工作…', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index edd6f9b2b9..e964060931 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2126,7 +2126,11 @@ export const zh: Translations = { message: count => `在您检查模型设置之前,${count} 个定时任务将被跳过。`, detailMore: (names, remaining) => `${names},以及另外 ${remaining} 个`, review: '检查定时任务', - saveFailed: 'Hermes 未保存该模型更改。' + saveFailed: 'Hermes 未保存该模型更改。', + confirmTitle: '模型选择警告', + confirmDetail: '仅在你接受此权衡时确认。', + confirmAction: '确认', + declined: '已取消模型更改 — 你拒绝了数据训练层级警告。' }, search: '搜索定时任务…', loading: '正在加载定时任务…', diff --git a/apps/desktop/src/store/cron-model-impact.test.ts b/apps/desktop/src/store/cron-model-impact.test.ts index f854808809..56c101e867 100644 --- a/apps/desktop/src/store/cron-model-impact.test.ts +++ b/apps/desktop/src/store/cron-model-impact.test.ts @@ -20,6 +20,16 @@ import { import { deferred } from '../test/deferred' +async function waitForConfirmToast() { + return vi.waitFor(() => { + const toast = $notifications.get().find(item => item.id.startsWith('model-warning-confirm-')) + + expect(toast).toBeDefined() + + return toast! + }) +} + function response(impact: ModelAssignmentResponse['cron_model_impact']): ModelAssignmentResponse { return { ok: true, @@ -114,7 +124,43 @@ describe('setMainModelAssignment', () => { expect($notifications.get()).toEqual([]) }) - it('rejects non-persisted confirmation outcomes without changing impact state', async () => { + it('prompts and retries with confirm_expensive_model when the user accepts', async () => { + setModelAssignment.mockResolvedValueOnce(response(positive())) + await setMainModelAssignment({ provider: 'nous', model: 'one' }) + + const confirmResponse = { + ok: false, + scope: 'main', + provider: 'openrouter', + model: 'openai/gpt-5.5-pro', + confirm_required: true, + confirm_message: 'Confirm this expensive model.' + } satisfies ModelAssignmentResponse + + setModelAssignment.mockResolvedValueOnce(confirmResponse) + setModelAssignment.mockResolvedValueOnce(response(positive('Confirmed job'))) + + const pending = setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' }) + const confirm = await waitForConfirmToast() + + expect(confirm.kind).toBe('warning') + expect(confirm.message).toBe('Confirm this expensive model.') + expect(confirm.action?.label).toBe('Confirm') + + confirm.action?.onClick() + await pending + + expect(setModelAssignment).toHaveBeenLastCalledWith( + expect.objectContaining({ + provider: 'openrouter', + model: 'openai/gpt-5.5-pro', + confirm_expensive_model: true + }) + ) + expect($notifications.get().some(item => item.detail?.includes('Confirmed job'))).toBe(true) + }) + + it('declines without retrying when the user dismisses the guard prompt', async () => { setModelAssignment.mockResolvedValueOnce(response(positive())) await setMainModelAssignment({ provider: 'nous', model: 'one' }) @@ -127,16 +173,61 @@ describe('setMainModelAssignment', () => { confirm_message: 'Confirm this expensive model.' } satisfies ModelAssignmentResponse) - await expect(setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' })).rejects.toThrow( - 'Confirm this expensive model.' - ) - expect($notifications.get()).toHaveLength(1) - const action = $notifications.get()[0].action + const pending = setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' }) + const confirm = await waitForConfirmToast() + + dismissNotification(confirm.id) + + await expect(pending).rejects.toThrow('Model change cancelled') + expect(setModelAssignment.mock.calls).toHaveLength(2) + + const impact = $notifications.get().find(item => item.id === CRON_MODEL_IMPACT_NOTIFICATION_ID) const reviewCount = $cronReviewRequest.get() - action?.onClick() + impact?.action?.onClick() expect($cronReviewRequest.get()).toBe(reviewCount + 1) }) + it('fails closed without a prompt when skipConfirmPrompt is set', async () => { + setModelAssignment.mockResolvedValueOnce({ + ok: false, + scope: 'main', + provider: 'openrouter', + model: 'openai/gpt-5.5-pro', + confirm_required: true, + confirm_message: 'Confirm this expensive model.' + } satisfies ModelAssignmentResponse) + + await expect( + setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' }, undefined, { + skipConfirmPrompt: true + }) + ).rejects.toThrow('Confirm this expensive model.') + expect(setModelAssignment).toHaveBeenCalledTimes(1) + expect($notifications.get()).toEqual([]) + }) + + it('does not recurse when the backend still demands confirm after ack', async () => { + const confirmResponse = { + ok: false, + scope: 'main', + provider: 'openrouter', + model: 'openai/gpt-5.5-pro', + confirm_required: true, + confirm_message: 'Confirm this expensive model.' + } satisfies ModelAssignmentResponse + + setModelAssignment.mockResolvedValueOnce(confirmResponse) + setModelAssignment.mockResolvedValueOnce(confirmResponse) + + const pending = setMainModelAssignment({ provider: 'openrouter', model: 'openai/gpt-5.5-pro' }) + const confirm = await waitForConfirmToast() + + confirm.action?.onClick() + + await expect(pending).rejects.toThrow('Confirm this expensive model.') + expect(setModelAssignment).toHaveBeenCalledTimes(2) + }) + it('publishes only the latest same-profile assignment when responses reverse', async () => { const first = deferred() const second = deferred() diff --git a/apps/desktop/src/store/cron-model-impact.ts b/apps/desktop/src/store/cron-model-impact.ts index 798149cc28..1d8d7016a8 100644 --- a/apps/desktop/src/store/cron-model-impact.ts +++ b/apps/desktop/src/store/cron-model-impact.ts @@ -146,19 +146,44 @@ function publishImpact(impact: CronModelImpact, profile: string, connection: str export async function setMainModelAssignment( request: Omit, - scopeProfile?: null | string + scopeProfile?: null | string, + options?: { skipConfirmPrompt?: boolean } ): Promise { const { connection, generation } = beginCronModelImpactAssignment() const profile = profileIdentity() // Only pass the extra arg when a scope override exists, so unscoped callers // keep the exact legacy call shape. - const result = + const assign = (body: Omit) => scopeProfile == null - ? await setModelAssignment({ ...request, scope: 'main' }) - : await setModelAssignment({ ...request, scope: 'main' }, scopeProfile) + ? setModelAssignment({ ...body, scope: 'main' }) + : setModelAssignment({ ...body, scope: 'main' }, scopeProfile) - if (result.ok !== true) { + let result = await assign(request) + + // Backend demands an explicit ack before persisting a model that trips a + // selection guard (expensive / data-training tiers like *-contributor). + // Settings used to throw confirm_message as a red error, so Apply could + // never persist. Prompt, then retry with confirm_expensive_model. + if (result.confirm_required) { + if (request.confirm_expensive_model || options?.skipConfirmPrompt) { + // Already acked, or headless onboarding (nothing mounted to click). + // Fail closed instead of recursing / dangling a prompt. + throw new Error(result.confirm_message?.trim() || translateNow('cron.modelImpact.saveFailed')) + } + + const accepted = await confirmModelWarning(result.confirm_message?.trim() ?? '') + + if (!accepted) { + throw new Error(translateNow('cron.modelImpact.declined')) + } + + result = await assign({ ...request, confirm_expensive_model: true }) + + if (result.confirm_required || result.ok !== true) { + throw new Error(result.confirm_message?.trim() || translateNow('cron.modelImpact.saveFailed')) + } + } else if (result.ok !== true) { throw new Error(result.confirm_message?.trim() || translateNow('cron.modelImpact.saveFailed')) } @@ -199,3 +224,38 @@ export function invalidateCronModelImpactScope(options: { clearNotification?: bo // Scope changes originating outside this module (profile/backend switches) // clear any warning that belongs to the old runtime. onCronModelImpactScopeInvalidated(() => dismissNotification(CRON_MODEL_IMPACT_NOTIFICATION_ID)) + +/** + * Selection-guard warning as a confirm toast. Resolves true on Confirm, false + * on dismiss. The desktop has no blocking confirm API; this is the same + * notify-with-action pattern the in-session model picker uses. + */ +function confirmModelWarning(message: string): Promise { + const id = `model-warning-confirm-${Date.now()}` + + return new Promise(resolve => { + let settled = false + const finish = (value: boolean) => { + if (settled) { + return + } + + settled = true + dismissNotification(id) + resolve(value) + } + + notify({ + id, + kind: 'warning', + title: translateNow('cron.modelImpact.confirmTitle'), + message: message || translateNow('cron.modelImpact.confirmDetail'), + detail: translateNow('cron.modelImpact.confirmDetail'), + action: { + label: translateNow('cron.modelImpact.confirmAction'), + onClick: () => finish(true) + }, + onDismiss: () => finish(false) + }) + }) +} diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index f00bdd09cc..4f39fca3bc 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -1331,6 +1331,8 @@ export interface ModelAssignmentRequest { /** OpenAI-compatible endpoint URL. Only honored for custom/local providers * on the main slot — wires a self-hosted endpoint into runtime resolution. */ base_url?: string + /** Ack for selection-guard warnings (expensive / data-training tiers). */ + confirm_expensive_model?: boolean model: string provider: string scope: 'main' | 'auxiliary'