diff --git a/apps/desktop/src/renderer/__tests__/authContextRace.test.ts b/apps/desktop/src/renderer/__tests__/authContextRace.test.ts index f12255ab5ac..a73eef1ac13 100644 --- a/apps/desktop/src/renderer/__tests__/authContextRace.test.ts +++ b/apps/desktop/src/renderer/__tests__/authContextRace.test.ts @@ -41,6 +41,7 @@ describe('AuthContext auth-state races', () => { 'setProviderModelMemoryOwner(state.dataOwnerId);', 'setModelEnginePrefsOwner(state.dataOwnerId);', 'setModelFavoritesOwner(state.dataOwnerId);', + 'setRecentModelsOwner(state.dataOwnerId);', ]) { expect(applyBlock).toContain(call); } diff --git a/apps/desktop/src/renderer/__tests__/authContextSessionBoundary.test.tsx b/apps/desktop/src/renderer/__tests__/authContextSessionBoundary.test.tsx index 65f3e2022d2..34656617ea3 100644 --- a/apps/desktop/src/renderer/__tests__/authContextSessionBoundary.test.tsx +++ b/apps/desktop/src/renderer/__tests__/authContextSessionBoundary.test.tsx @@ -109,6 +109,11 @@ import { addModelFavorite, listModelFavorites, } from '@/state/modelFavorites'; +import { + __resetForTest as resetRecentModels, + listRecentModels, + recordRecentModel, +} from '@/state/recentModels'; import { __testing as ssoOrgHistoryTesting } from '@/state/ssoOrgHistory'; function user(id: string) { @@ -865,13 +870,15 @@ describe('AuthContext session cache boundaries', () => { * applyIncomingState,不能自己拼半套 setter —— 漏接任一 owner 分区都会让本地模式 * 读写上一个身份的数据:跨身份可见,还会把改动写进别人的账号。 */ - it('repartitions unified-picker favorites and engine overrides across local mode', async () => { + it('repartitions unified-picker favorites, recents and engine overrides across local mode', async () => { resetFavorites(); resetEnginePrefs(); + resetRecentModels(); const view = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(view.result.current.user?.id).toBe('account-a')); const cloudUid = addModelFavorite({ providerId: 'xd', modelId: 'gpt-5.5', agent: 'codex' }); setModelEngineOverride('xd', 'gpt-5.5', 'cc'); + recordRecentModel({ providerId: 'xd', modelId: 'gpt-5.5', agent: 'codex' }, 1000); expect(cloudUid).not.toBe(''); await act(async () => { @@ -880,12 +887,14 @@ describe('AuthContext session cache boundaries', () => { // 本地模式是另一个分区:云端身份存的东西一条都不该露出来。 expect(listModelFavorites()).toHaveLength(0); expect(getModelEngineOverride('xd', 'gpt-5.5')).toBeUndefined(); + expect(listRecentModels()).toHaveLength(0); const localUid = addModelFavorite({ providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'cc', }); setModelEngineOverride('anthropic', 'claude-opus-5', 'codex'); + recordRecentModel({ providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'cc' }, 2000); expect(listModelFavorites().map((item) => item.uid)).toEqual([localUid]); mocks.service.exitLocalMode.mockResolvedValue(authState('account-a')); @@ -896,8 +905,10 @@ describe('AuthContext session cache boundaries', () => { expect(listModelFavorites().map((item) => item.uid)).toEqual([cloudUid]); expect(getModelEngineOverride('xd', 'gpt-5.5')).toBe('cc'); expect(getModelEngineOverride('anthropic', 'claude-opus-5')).toBeUndefined(); + expect(listRecentModels().map((item) => item.modelId)).toEqual(['gpt-5.5']); resetFavorites(); resetEnginePrefs(); + resetRecentModels(); window.localStorage.clear(); }); diff --git a/apps/desktop/src/renderer/__tests__/recentModels.test.ts b/apps/desktop/src/renderer/__tests__/recentModels.test.ts new file mode 100644 index 00000000000..98869769de0 --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/recentModels.test.ts @@ -0,0 +1,351 @@ +/** + * recentModels.test.ts + * --------------------------------------------------------------------------- + * 回归 state/recentModels.ts 的核心约定(统一模型选择器「最近」视图): + * 1. 默认空列表,不写 localStorage + * 2. record 落盘 + usedAt 倒序 + 重复记录移到最前(时间取新) + * 3. 去重按**副本身份**(来源 + 模型 + 引擎 + 深度 + Fast):同一模型不同配置各占一行, + * 完全相同的副本只留一条、usedAt 取 max(时钟回拨不降级) + * 4. 容量裁剪:只留最近 RECENT_MODELS_KEEP 条 + * 5. sanitize:非法条目丢弃、引擎非法整条丢、providerId 拒绝保留位 '*'、重复副本取最新、排序倒序 + * 6. dataOwnerId 分区隔离 + * 7. storage 事件跨窗口重读,迟到旧事件不回滚 + * 8. 落盘失败静默吞,内存态仍生效 + * + * 项目 vitest env=node,无 window。沿用 modelFavorites.test.ts 的最小 localStorage stub。 + */ + +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; + +class MemLocalStorage { + private store = new Map(); + onWrite: ((key: string) => void) | null = null; + getItem(k: string): string | null { + return this.store.has(k) ? (this.store.get(k) as string) : null; + } + setItem(k: string, v: string): void { + this.store.set(k, v); + this.onWrite?.(k); + } + removeItem(k: string): void { + this.store.delete(k); + } + clear(): void { + this.store.clear(); + } +} + +let memStorage: MemLocalStorage; + +beforeEach(() => { + memStorage = new MemLocalStorage(); + vi.stubGlobal('window', { localStorage: memStorage }); + vi.stubGlobal('localStorage', memStorage); + vi.resetModules(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +/** 「另一窗口写盘 → 广播 storage 事件」的最小总线。 */ +function installStorageBus(): void { + const handlers: Array<(event: StorageEvent) => void> = []; + vi.stubGlobal('window', { + localStorage: memStorage, + addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'storage' && typeof listener === 'function') { + handlers.push(listener as (event: StorageEvent) => void); + } + }, + removeEventListener: vi.fn(), + }); + memStorage.onWrite = (key: string) => { + queueMicrotask(() => { + for (const handler of handlers) handler({ key } as StorageEvent); + }); + }; +} + +/** `navigator.locks` 的最小串行队列 polyfill(node env 没有 Web Locks;同 modelEnginePrefs.test)。 */ +function installLocks(): void { + const tails = new Map>(); + vi.stubGlobal('navigator', { + locks: { + request: (name: string, callback: () => unknown) => { + const previous = tails.get(name) ?? Promise.resolve(); + const run = previous.then(() => callback()); + tails.set( + name, + run.catch(() => undefined), + ); + return run; + }, + }, + }); +} + +/** 等锁内调和跑完(microtask + 事件队列各让几轮)。 */ +async function flushAsync(): Promise { + for (let i = 0; i < 4; i += 1) await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function loadModule() { + return await import('@/state/recentModels'); +} + +const OPUS = { providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'cc' } as const; +const SOL = { providerId: 'openai', modelId: 'gpt-5.6-sol', agent: 'codex' } as const; + +describe('recentModels store', () => { + it('默认空列表,不落盘', async () => { + const m = await loadModule(); + expect(m.listRecentModels()).toEqual([]); + expect(memStorage.getItem(m.__STORAGE_KEY)).toBeNull(); + }); + + it('记录后倒序陈列,重复记录移到最前并更新时间', async () => { + const m = await loadModule(); + m.recordRecentModel(OPUS, 1000); + m.recordRecentModel(SOL, 2000); + expect(m.listRecentModels().map((item) => item.modelId)).toEqual([ + 'gpt-5.6-sol', + 'claude-opus-5', + ]); + + m.recordRecentModel(OPUS, 3000); + const items = m.listRecentModels(); + expect(items.map((item) => item.modelId)).toEqual(['claude-opus-5', 'gpt-5.6-sol']); + expect(items[0]?.usedAt).toBe(3000); + // 去重:同一份副本不堆第二条。 + expect(items).toHaveLength(2); + }); + + it('按副本身份去重:同一模型的不同配置各占一行', async () => { + const m = await loadModule(); + m.recordRecentModel(OPUS, 1000); + m.recordRecentModel({ ...OPUS, effort: 'high' }, 2000); + m.recordRecentModel({ ...OPUS, fast: true }, 3000); + const items = m.listRecentModels(); + expect(items).toHaveLength(3); + expect(items.map((item) => `${item.effort ?? '-'}${item.fast ? '+fast' : ''}`)).toEqual([ + '-+fast', + 'high', + '-', + ]); + // 完全相同的副本(含 effort / fast 全同)仍然只留一条。 + m.recordRecentModel({ ...OPUS, effort: 'high' }, 4000); + expect(m.listRecentModels()).toHaveLength(3); + expect(m.listRecentModels()[0]).toMatchObject({ effort: 'high', usedAt: 4000 }); + }); + + it('落盘内容与列表一致,可跨重启恢复(同一 key 重读)', async () => { + const m = await loadModule(); + m.recordRecentModel(OPUS, 1000); + const persisted = JSON.parse(memStorage.getItem(m.__STORAGE_KEY) as string) as { + items: Array<{ modelId: string; usedAt: number }>; + }; + expect(persisted.items).toEqual([ + { providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'cc', usedAt: 1000 }, + ]); + // 模拟重启:清缓存后重读同一份 localStorage。 + m.__resetForTest(); + memStorage.setItem( + m.__STORAGE_KEY, + JSON.stringify({ + items: [{ providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'cc', usedAt: 1000 }], + }), + ); + expect(m.listRecentModels().map((item) => item.modelId)).toEqual(['claude-opus-5']); + }); + + it('非法入参静默 no-op,不写脏数据', async () => { + const m = await loadModule(); + m.recordRecentModel({ providerId: '*', modelId: 'x', agent: 'cc' }, 1); + m.recordRecentModel({ providerId: 'anthropic', modelId: '', agent: 'cc' }, 1); + // agent 非法 → 整条丢弃(配置副本缺引擎无法表达任何配置)。 + m.recordRecentModel({ providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'nope' } as never, 1); + m.recordRecentModel({ ...OPUS }, Number.NaN); + expect(m.listRecentModels()).toEqual([]); + expect(memStorage.getItem(m.__STORAGE_KEY)).toBeNull(); + }); + + it('时钟回拨:更旧的 usedAt 不把已有记录的时间往回拧', async () => { + const m = await loadModule(); + m.recordRecentModel(OPUS, 5000); + m.recordRecentModel(OPUS, 1000); + expect(m.listRecentModels()[0]?.usedAt).toBe(5000); + }); + + it('容量裁剪:只保留最近 RECENT_MODELS_KEEP 条', async () => { + const m = await loadModule(); + for (let i = 0; i < m.RECENT_MODELS_KEEP + 5; i += 1) { + m.recordRecentModel({ providerId: 'anthropic', modelId: `m-${i}`, agent: 'cc' }, 1000 + i); + } + const items = m.listRecentModels(); + expect(items).toHaveLength(m.RECENT_MODELS_KEEP); + // 保留的是最新的一批:队首是最后记的,队尾是第 (总数 - KEEP + 1) 条。 + expect(items[0]?.modelId).toBe(`m-${m.RECENT_MODELS_KEEP + 4}`); + expect(items[items.length - 1]?.modelId).toBe('m-5'); + }); + + it('sanitize:损坏 / 越界条目丢弃,重复副本取最新,排序倒序', async () => { + const m = await loadModule(); + memStorage.setItem( + m.__STORAGE_KEY, + JSON.stringify({ + items: [ + null, + 'nope', + { providerId: '*', modelId: 'reserved', agent: 'cc', usedAt: 9 }, + { providerId: 'anthropic', modelId: '', agent: 'cc', usedAt: 9 }, + { providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'nope', usedAt: 9 }, + { providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'cc', usedAt: 'oops' }, + { providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'cc', usedAt: 100 }, + { providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'cc', usedAt: 300 }, + { providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'cc', effort: 'high', usedAt: 200 }, + ], + }), + ); + expect(m.listRecentModels()).toEqual([ + { providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'cc', usedAt: 300 }, + { providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'cc', effort: 'high', usedAt: 200 }, + ]); + }); + + it('写失败后再写成功:磁盘旧快照不能把只存在内存里的那条顶掉', async () => { + const m = await loadModule(); + m.recordRecentModel(OPUS, 1000); + // 配额满:B 没落盘,只活在内存里(缓存仍更新)。 + vi.spyOn(memStorage, 'setItem').mockImplementationOnce(() => { + throw new Error('quota'); + }); + m.recordRecentModel(SOL, 2000); + expect(m.listRecentModels().map((item) => item.modelId)).toEqual([ + 'gpt-5.6-sol', + 'claude-opus-5', + ]); + // 下一次成功写入的基底 = 磁盘真相 ∪ 内存态,不能只看磁盘(否则 B 被抹掉)。 + m.recordRecentModel({ providerId: 'anthropic', modelId: 'claude-sonnet-5', agent: 'cc' }, 3000); + expect( + m + .listRecentModels() + .map((item) => item.modelId) + .sort(), + ).toEqual(['claude-opus-5', 'claude-sonnet-5', 'gpt-5.6-sol']); + const persisted = JSON.parse(memStorage.getItem(m.__STORAGE_KEY) as string) as { + items: Array<{ modelId: string }>; + }; + expect(persisted.items.map((item) => item.modelId).sort()).toEqual([ + 'claude-opus-5', + 'claude-sonnet-5', + 'gpt-5.6-sol', + ]); + }); + + it('跨窗口迟到覆盖:锁内重放 op-log 把本窗的记录重新断言(两份都留;重复调和幂等)', async () => { + installLocks(); + installStorageBus(); + const m = await loadModule(); + m.recordRecentModel(OPUS, 1000); + // 别窗拿旧基底做的整表覆盖:磁盘上只剩对方那一条。 + memStorage.setItem( + m.__STORAGE_KEY, + JSON.stringify({ + items: [{ providerId: 'openai', modelId: 'gpt-5.6-sol', agent: 'codex', usedAt: 2000 }], + }), + ); + await flushAsync(); + // 本窗的 op 被重新断言:A 回来了(按 usedAt 排在 B 后面)。 + expect(m.listRecentModels().map((item) => item.modelId)).toEqual([ + 'gpt-5.6-sol', + 'claude-opus-5', + ]); + // 再广播一次:无差异不写,状态收敛(不活锁)。 + const diskBefore = memStorage.getItem(m.__STORAGE_KEY); + memStorage.setItem( + m.__STORAGE_KEY, + JSON.stringify({ + items: [ + { providerId: 'openai', modelId: 'gpt-5.6-sol', agent: 'codex', usedAt: 2000 }, + { providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'cc', usedAt: 1000 }, + ], + }), + ); + await flushAsync(); + expect(memStorage.getItem(m.__STORAGE_KEY)).toBe(diskBefore); + expect(m.listRecentModels()).toHaveLength(2); + }); + + it('切换 owner 后旧分区仍按原 key 调和,不写进新分区', async () => { + installLocks(); + installStorageBus(); + const m = await loadModule(); + m.setRecentModelsOwner('user-a'); + const keyA = `${m.__STORAGE_KEY}:user-a`; + m.recordRecentModel(OPUS, 1000); + m.setRecentModelsOwner('user-b'); + // 别窗迟到覆盖 A 分区。 + memStorage.setItem( + keyA, + JSON.stringify({ + items: [{ providerId: 'openai', modelId: 'gpt-5.6-sol', agent: 'codex', usedAt: 2000 }], + }), + ); + await flushAsync(); + const persistedA = JSON.parse(memStorage.getItem(keyA) as string) as { + items: Array<{ modelId: string }>; + }; + expect(persistedA.items.map((item) => item.modelId).sort()).toEqual([ + 'claude-opus-5', + 'gpt-5.6-sol', + ]); + // B 分区不受影响,当前视图也不串数据。 + expect(memStorage.getItem(`${m.__STORAGE_KEY}:user-b`)).toBeNull(); + expect(m.listRecentModels()).toEqual([]); + }); + + it('storage 事件重读真相,且不采信迟到的旧事件', async () => { + installStorageBus(); + const m = await loadModule(); + m.recordRecentModel(OPUS, 1000); + // 另一窗口写入更完整的状态(真实浏览器不回送写入方,这里手动广播)。 + memStorage.setItem( + m.__STORAGE_KEY, + JSON.stringify({ + items: [ + { providerId: 'openai', modelId: 'gpt-5.6-sol', agent: 'codex', usedAt: 2000 }, + { providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'cc', usedAt: 1000 }, + ], + }), + ); + await Promise.resolve(); + expect(m.listRecentModels().map((item) => item.modelId)).toEqual([ + 'gpt-5.6-sol', + 'claude-opus-5', + ]); + }); + + it('落盘失败静默吞,内存态仍生效', async () => { + const m = await loadModule(); + vi.spyOn(memStorage, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + expect(() => m.recordRecentModel(OPUS, 1000)).not.toThrow(); + expect(m.listRecentModels().map((item) => item.modelId)).toEqual(['claude-opus-5']); + }); + + it('dataOwnerId 分区隔离:切号后各读各的', async () => { + const m = await loadModule(); + m.setRecentModelsOwner('user-a'); + m.recordRecentModel(OPUS, 1000); + m.setRecentModelsOwner('user-b'); + expect(m.listRecentModels()).toEqual([]); + m.recordRecentModel(SOL, 2000); + m.setRecentModelsOwner('user-a'); + expect(m.listRecentModels().map((item) => item.modelId)).toEqual(['claude-opus-5']); + m.setRecentModelsOwner('user-b'); + expect(m.listRecentModels().map((item) => item.modelId)).toEqual(['gpt-5.6-sol']); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx b/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx index 90a27f9cbec..3e8f5e9e266 100644 --- a/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx +++ b/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx @@ -10,7 +10,8 @@ * 3. 点自定义 / 右键弹出配置浮层,浮层里的引擎胶囊只列候选引擎; * 4. 点引擎胶囊 → 写 modelEnginePrefs override,行三元组当场跟着变; * 5. 点 ☆ → 写 modelFavorites 配置副本,收藏区置顶出现; - * 6. 点行 → 按该行生效配置回调 (provider, model, effort)。 + * 6. 点行 → 按该行生效配置回调 (provider, model, effort); + * 7. recordRecentUsage 开启时选择成功才写 recentModels(「最近」区当场出现)。 */ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; @@ -33,6 +34,8 @@ vi.mock('react-i18next', async (importOriginal) => ({ 'newChat.modelSelector.search.noResults': '无匹配模型', 'newChat.modelSelector.search.placeholderAll': '搜索模型…', 'newChat.modelSelector.unified.favoritesGroup': '收藏', + 'newChat.modelSelector.unified.recentGroup': '最近', + 'newChat.modelSelector.unified.recentEmpty': '还没有最近使用的模型', 'newChat.modelSelector.unified.recommended': '推荐', 'newChat.modelSelector.unified.addFavorite': '存为收藏', 'newChat.modelSelector.unified.customize': '自定义', @@ -41,6 +44,7 @@ vi.mock('react-i18next', async (importOriginal) => ({ 'newChat.modelSelector.unified.customized': '已自定义', 'newChat.modelSelector.unified.reset': '恢复推荐', 'newChat.modelSelector.unified.railAll': '全部', + 'newChat.modelSelector.unified.railRecent': '最近', 'newChat.modelSelector.unified.railSameEngine': `仅 ${options?.agent ?? ''}`, 'newChat.modelSelector.unified.crossEngineWarning': '切换引擎会重建上下文,可能丢失内容', 'newChat.modelSelector.category.anthropic': 'Anthropic', @@ -218,6 +222,12 @@ import { listModelFavorites, updateModelFavorite, } from '@/state/modelFavorites'; +import { + __resetForTest as resetRecentModels, + listRecentModels, + recordRecentModel, + setRecentModelsOwner, +} from '@/state/recentModels'; import { setModelEngineOverride } from '@/state/modelEnginePrefs'; const onProviderChange = vi.fn(); @@ -263,6 +273,7 @@ beforeEach(() => { onProviderChange.mockClear(); resetEnginePrefs(); resetFavorites(); + resetRecentModels(); }); describe('统一面板 · 打折 GPT-5.6 与 GPT-6 包月互切', () => { @@ -4129,7 +4140,6 @@ it('teammate fallback exposes supported Harness choices and preserves the primar expect(change).toHaveBeenLastCalledWith([primary, expect.objectContaining({ harness: 'claude', providerId: 'openai', model: 'chatgpt/gpt-5.6' })]); }); - describe('harness configuration keeps the model menu open', () => { it.each(['success', 'cancel', 'error'] as const)('%s keeps both menus available', async (outcome) => { let finish!: (value: boolean) => void; @@ -4186,3 +4196,324 @@ describe('harness configuration keeps the model menu open', () => { .querySelector('[data-engine-capsule="codex"]')?.getAttribute('aria-pressed')).toBe('true'); }); }); + +describe('统一面板 · 最近使用记录与陈列', () => { + it('默认不记录(非对话入口不开 recordRecentUsage)', async () => { + renderPanel(); + await act(async () => { + fireEvent.click(rowFor('GPT-5.5')); + }); + expect(listRecentModels()).toEqual([]); + expect(document.querySelector('[data-group-label="最近"]')).toBeNull(); + }); + + it('开启后选择成功即记(归一化行身份),但常规视图不陈列最近区', async () => { + renderPanel({ recordRecentUsage: true }); + await act(async () => { + fireEvent.click(rowFor('GPT-5.6')); + }); + expect(listRecentModels().map((item) => `${item.providerId}:${item.modelId}`)).toEqual([ + 'openai:gpt-5.6', + ]); + // 最近只作为侧栏「最近」格的独立视图,不再混进全部 / 供应商列表(2026-09-16 实测裁决)。 + expect(document.querySelector('[data-group-label="最近"]')).toBeNull(); + }); + + it('选择失败(false)不记', async () => { + onProviderChange.mockImplementationOnce(() => false); + renderPanel({ recordRecentUsage: true }); + await act(async () => { + fireEvent.click(rowFor('GPT-5.5')); + }); + expect(listRecentModels()).toEqual([]); + }); + + it('收藏行(存的是旧 wire id)记的是归一化行身份', async () => { + addModelFavorite({ providerId: 'openai', modelId: 'chatgpt/gpt-5.6', agent: 'cc' }); + renderPanel({ recordRecentUsage: true }); + const favRow = document.querySelector('[data-unified-anchor^="fav::"]'); + expect(favRow).not.toBeNull(); + await act(async () => { + fireEvent.click(favRow as HTMLElement); + }); + expect(listRecentModels().map((item) => item.modelId)).toEqual(['gpt-5.6']); + }); + + it('选择在途时切换归属:不把记录写进新分区(2026-09-17 review P1 跨账号串写)', async () => { + let settle: ((applied: boolean) => void) | null = null; + const pending = new Promise((resolve) => { + settle = resolve; + }); + renderPanel({ + recordRecentUsage: true, + onProviderChange: vi.fn(() => pending), + currentProviderId: 'openai', + modelId: 'gpt-5.5', + }); + await act(async () => { + fireEvent.click(rowFor('GPT-5.6')); + }); + // 选择还在飞:用户登出 / 切到本地模式(另一个分区)。 + await act(async () => { + setRecentModelsOwner('user-b'); + }); + await act(async () => { + settle?.(true); + await pending; + }); + // 异步链条(选模回调 → 记录判定)还要再让一拍,避免 act 警告。 + await act(async () => { + await Promise.resolve(); + }); + // 旧 Promise 落地时归属已变 → 整条丢弃,新分区看不到这条记录。 + expect(listRecentModels()).toEqual([]); + }); + + it('设置类入口(configurationEnabled=false)不渲染无响应的自定义按钮 / 不宣告 ←', async () => { + renderPanel({ configurationEnabled: false }); + const row = rowFor('GPT-5.5'); + // 之前只靠面板层 noop 中和,按钮与快捷键仍宣告存在(点了没反应)。 + expect(row.querySelector('[data-row-customize]')).toBeNull(); + expect(row.getAttribute('aria-keyshortcuts')).toBeNull(); + const contextMenu = new MouseEvent('contextmenu', { bubbles: true, cancelable: true }); + row.dispatchEvent(contextMenu); + expect(contextMenu.defaultPrevented).toBe(false); + await act(async () => { + fireEvent.keyDown(row, { key: 'ArrowLeft' }); + }); + expect(screen.queryByTestId('unified-model-config-flyout')).toBeNull(); + // 选模型本身不受影响。 + await act(async () => { + fireEvent.click(row); + }); + expect(onProviderChange).toHaveBeenCalledWith('xd', 'gpt-5.5', 'high', false); + }); + + it('rail 的「最近」格在收藏之上,点击后只留最近区', async () => { + recordRecentModel({ providerId: 'openai', modelId: 'gpt-5.6', agent: 'codex' }, 1000); + renderPanel(); + const railKeys = [...document.querySelectorAll('[data-rail-item]')].map((el) => + el.getAttribute('data-rail-item'), + ); + expect(railKeys.indexOf('recent')).toBeGreaterThanOrEqual(0); + expect(railKeys.indexOf('recent')).toBeLessThan(railKeys.indexOf('favorites')); + + await act(async () => { + fireEvent.click(document.querySelector('[data-rail-item="recent"]') as HTMLElement); + }); + const list = screen.getByRole('listbox'); + expect(within(list).getByText('GPT-5.6')).toBeTruthy(); + expect(within(list).queryByText('GPT-5.5')).toBeNull(); + // 最近面板与收藏面板一致:L2 显示 provider 来源(来源详情行),不是模型描述。 + const sourceRow = list.querySelector('[data-model-source-details]'); + expect(sourceRow?.textContent).toContain('OpenAI'); + expect(within(list).queryByText(/A very long English description/)).toBeNull(); + }); + + it('最近视图为空时显示引导空态', async () => { + renderPanel(); + await act(async () => { + fireEvent.click(document.querySelector('[data-rail-item="recent"]') as HTMLElement); + }); + expect(screen.getByText('还没有最近使用的模型')).toBeTruthy(); + }); + + it('点最近行按该副本的完整配置回调(引擎 / 深度一并带出)', async () => { + recordRecentModel( + { providerId: 'openai', modelId: 'gpt-5.6', agent: 'cc', effort: 'high' }, + 1000, + ); + renderPanel(); + await act(async () => { + fireEvent.click(document.querySelector('[data-rail-item="recent"]') as HTMLElement); + }); + await act(async () => { + fireEvent.click(rowFor('GPT-5.6')); + }); + // 副本存的是 cc 引擎 → 按 cc 的 wire id 与 high 档发出。 + expect(onProviderChange).toHaveBeenCalledWith( + 'openai', + 'chatgpt/gpt-5.6', + 'high', + expect.any(Boolean), + ); + }); + + it('最近行的星标按配置副本匹配收藏:未收藏点星新增,已收藏点星取消', async () => { + recordRecentModel({ providerId: 'openai', modelId: 'gpt-5.6', agent: 'codex' }, 1000); + renderPanel(); + await act(async () => { + fireEvent.click(document.querySelector('[data-rail-item="recent"]') as HTMLElement); + }); + // 未收藏:悬停星是「存为收藏」,点一下把这份副本存进收藏。 + await act(async () => { + fireEvent.click(within(screen.getByRole('listbox')).getByRole('button', { name: '存为收藏' })); + }); + expect(listModelFavorites()).toHaveLength(1); + expect(listModelFavorites()[0]).toMatchObject({ + providerId: 'openai', + modelId: 'gpt-5.6', + agent: 'codex', + }); + // 星标点亮(按钮语义变为「取消收藏」)。 + expect( + within(screen.getByRole('listbox')).getByRole('button', { name: '取消收藏' }), + ).toBeTruthy(); + + // 再点一次 → 移除匹配的那条收藏,星灭。 + await act(async () => { + fireEvent.click(within(screen.getByRole('listbox')).getByRole('button', { name: '取消收藏' })); + }); + expect(listModelFavorites()).toHaveLength(0); + expect(within(screen.getByRole('listbox')).getByRole('button', { name: '存为收藏' })).toBeTruthy(); + }); + + it('已收藏的副本在最近视图里直接亮星,点星移除对应收藏', async () => { + addModelFavorite({ providerId: 'openai', modelId: 'gpt-5.6', agent: 'codex' }); + recordRecentModel({ providerId: 'openai', modelId: 'gpt-5.6', agent: 'codex' }, 1000); + renderPanel(); + await act(async () => { + fireEvent.click(document.querySelector('[data-rail-item="recent"]') as HTMLElement); + }); + await act(async () => { + fireEvent.click(within(screen.getByRole('listbox')).getByRole('button', { name: '取消收藏' })); + }); + expect(listModelFavorites()).toHaveLength(0); + }); + + it('星标先圈定来源 + 模型:另一型号的收藏即使三元组巧合一致也不误命中(2026-09-16 review P0)', async () => { + // gpt-5.5 的收藏解析后与 gpt-5.6 的这条副本同引擎 / 同档 / 同 Fast —— 只有模型不同。 + addModelFavorite({ providerId: 'openai', modelId: 'gpt-5.5', agent: 'codex' }); + recordRecentModel({ providerId: 'openai', modelId: 'gpt-5.6', agent: 'codex' }, 1000); + renderPanel(); + await act(async () => { + fireEvent.click(document.querySelector('[data-rail-item="recent"]') as HTMLElement); + }); + const list = screen.getByRole('listbox'); + // 不能点亮:本行并没有被收藏。 + expect(within(list).getByRole('button', { name: '存为收藏' })).toBeTruthy(); + // 点星只能新增 gpt-5.6 这条,绝不能把 gpt-5.5 的收藏删掉。 + await act(async () => { + fireEvent.click(within(list).getByRole('button', { name: '存为收藏' })); + }); + expect( + listModelFavorites() + .map((item) => item.modelId) + .sort(), + ).toEqual(['gpt-5.5', 'gpt-5.6']); + }); + + it('点已标星的最近行 = 按收藏副本选中(锚点带上那条收藏的 uid)', async () => { + const uid = addModelFavorite({ providerId: 'openai', modelId: 'gpt-5.6', agent: 'codex' }); + expect(uid).not.toBe(''); + recordRecentModel({ providerId: 'openai', modelId: 'gpt-5.6', agent: 'codex' }, 1000); + const onSessionFavoriteAnchorChange = vi.fn(); + renderPanel({ onSessionFavoriteAnchorChange }); + await act(async () => { + fireEvent.click(document.querySelector('[data-rail-item="recent"]') as HTMLElement); + }); + await act(async () => { + fireEvent.click(rowFor('GPT-5.6')); + }); + // 选择真的应用(同引擎链路回调拿到行配置)…… + expect(onProviderChange).toHaveBeenCalledWith('openai', 'gpt-5.6', 'medium', false); + // ……锚点才记上命中的那条收藏(与收藏行同构;不传就是「选中态回落到模型行」)。 + expect(onSessionFavoriteAnchorChange).toHaveBeenCalledWith( + expect.objectContaining({ uid, engine: 'codex', providerId: 'openai' }), + ); + }); + + it('最近行不提供配置入口:不宣告 ← / 不抢右键 / 按 ← 不开浮层(2026-09-16 review P1)', async () => { + recordRecentModel({ providerId: 'openai', modelId: 'gpt-5.6', agent: 'codex' }, 1000); + renderPanel(); + await act(async () => { + fireEvent.click(document.querySelector('[data-rail-item="recent"]') as HTMLElement); + }); + const row = rowFor('GPT-5.6'); + expect(row.getAttribute('aria-keyshortcuts')).toBeNull(); + expect(row.querySelector('[data-row-customize]')).toBeNull(); + // 右键不 preventDefault(不吞原生菜单),也不开浮层。 + const contextMenu = new MouseEvent('contextmenu', { bubbles: true, cancelable: true }); + await act(async () => { + row.dispatchEvent(contextMenu); + }); + expect(contextMenu.defaultPrevented).toBe(false); + expect(screen.queryByTestId('unified-model-config-flyout')).toBeNull(); + await act(async () => { + fireEvent.keyDown(row, { key: 'ArrowLeft' }); + }); + expect(screen.queryByTestId('unified-model-config-flyout')).toBeNull(); + }); + + it('在最近视图取消「当前选中的那份收藏」:与收藏视图同一口径——先把默认配置应用出去再删', async () => { + const uid = addModelFavorite({ + providerId: 'xd', + modelId: 'gpt-5.5', + agent: 'cc', + effort: 'low', + }); + recordRecentModel({ providerId: 'xd', modelId: 'gpt-5.5', agent: 'cc', effort: 'low' }, 1000); + const onUnifiedSelect = vi.fn(); + renderPanel({ + onUnifiedSelect, + selectedFavoriteUid: uid, + currentProviderId: 'xd', + modelId: 'gpt-5.5', + vendorKey: 'cc', + effort: 'low', + fastMode: false, + }); + await act(async () => { + fireEvent.click(document.querySelector('[data-rail-item="recent"]') as HTMLElement); + }); + await act(async () => { + fireEvent.click(within(screen.getByRole('listbox')).getByRole('button', { name: '取消收藏' })); + }); + // 删的正是正在跑的那一份 → 先把默认配置应用出去(与收藏视图逐字同一条链路), + // 再删记录;否则配置与选中态会分家。 + expect(onUnifiedSelect).toHaveBeenCalledWith({ + providerId: 'xd', + modelId: 'gpt-5.5', + engine: 'codex', + effort: 'high', + fast: false, + favoriteUid: null, + resetToRecommended: true, + }); + expect(listModelFavorites()).toHaveLength(0); + }); + + it('会话跨引擎:点已标星的最近行仍走切换事务,并把命中的收藏 uid 带过去', async () => { + const uid = addModelFavorite({ + providerId: 'anthropic', + modelId: 'claude-opus-5', + agent: 'cc', + }); + recordRecentModel({ providerId: 'anthropic', modelId: 'claude-opus-5', agent: 'cc' }, 1000); + const onCrossEngineSelect = vi.fn(); + renderPanel({ + sessionEngineFilter: { + currentAgent: 'codex' as const, + onCrossEngineSelect, + }, + vendorKey: 'codex', + currentProviderId: 'openai', + modelId: 'codex/gpt-5.6', + }); + await act(async () => { + fireEvent.click(document.querySelector('[data-rail-item="recent"]') as HTMLElement); + }); + await act(async () => { + fireEvent.click(rowFor('Opus 5')); + }); + // 跨引擎不走 onProviderChange(selectRow 内改道),锚点随事务一起交出去。 + expect(onProviderChange).not.toHaveBeenCalled(); + expect(onCrossEngineSelect).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: 'anthropic', + targetAgent: 'claude-code', + favoriteUid: uid, + }), + ); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts b/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts index 6aa716a162f..733840c205b 100644 --- a/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts +++ b/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts @@ -1,6 +1,6 @@ /** * 统一模型选择器(模型优先)M3 / M4 的**纯逻辑锁**:行生效配置合成、收藏副本语义、 - * 收藏置顶 + 分组陈列、rail 派生、浮层定位、档位绝对色。 + * 收藏 / 最近置顶 + 分组陈列、rail 派生、浮层定位、档位绝对色。 * * 为什么这些必须有测试:行右侧的三元组与浮层里的每个控件显示的是**同一份合成结果**, * 一旦两边规则漂移,用户会看到「行上写着 high、浮层滑杆停在 medium」这种自相矛盾; @@ -17,6 +17,7 @@ import type { import { UNIFIED_FLYOUT_GAP, + UNIFIED_RECENT_MODELS_LIMIT, buildUnifiedListSections, engineOfAgentKind, entryMatchesModelId, @@ -29,7 +30,9 @@ import { resolveFavoriteRowConfig, resolveUnifiedRowConfig, } from '@/components/new-chat/unifiedModelSelection'; +import type { ModelConfigCopy } from '@/state/modelConfigCopy'; import type { ModelFavoriteItem } from '@/state/modelFavorites'; +import type { RecentModelItem } from '@/state/recentModels'; import { EFFORT_TIER_COLORS, effortTierColor, @@ -79,6 +82,16 @@ function favoriteOf(over: Partial = {}): ModelFavoriteItem { }; } +function recentOf(over: Partial = {}): RecentModelItem { + return { + providerId: 'anthropic', + modelId: 'claude-opus-5', + agent: 'cc', + usedAt: 1, + ...over, + }; +} + describe('resolveUnifiedRowConfig', () => { it('无 override 时落推荐引擎与目录默认档', () => { const config = resolveUnifiedRowConfig({ entry: entryOf() }); @@ -395,6 +408,174 @@ describe('buildUnifiedListSections', () => { }); expect(sections[0].rows.map((row) => row.favorite?.uid)).toEqual(['fav-1', 'fav-2']); }); + + describe('最近视图(仅侧栏「最近」格)', () => { + it('常规视图(全部 / 供应商 / 同引擎)不陈列最近区 —— 即使 store 里有记录', () => { + for (const rail of [ + { kind: 'all' } as const, + { kind: 'provider', providerId: 'anthropic' } as const, + { kind: 'engine', agent: 'claude-code' } as const, + ]) { + const sections = buildUnifiedListSections({ + entries: [opus, gpt], + favorites: [favoriteOf()], + recentModels: [recentOf()], + query: '', + rail, + }); + expect(sections.some((section) => section.kind === 'recent')).toBe(false); + // 收藏区照旧置顶。 + expect(sections[0].kind).toBe('favorites'); + } + }); + + it('rail = 最近时只出最近区,行锚点是副本身份(带 recent 条目)', () => { + const sections = buildUnifiedListSections({ + entries: [opus, gpt], + favorites: [favoriteOf()], + recentModels: [recentOf({ providerId: 'openai', modelId: 'gpt-5.5' })], + query: '', + rail: { kind: 'recent' }, + recommendation: { agent: 'codex', providerId: 'openai', modelId: 'gpt-5.5' }, + }); + expect(sections.map((section) => section.kind)).toEqual(['recent']); + // 锚点是**副本身份**(同一模型不同配置各占一行);key 由 store 的去重身份算得。 + expect(sections[0].rows[0].anchor).toEqual({ + kind: 'recent', + key: expect.any(String), + providerId: 'openai', + modelId: 'gpt-5.5', + }); + expect(sections[0].rows[0].favorite).toBeUndefined(); + expect(sections[0].rows[0].recent).toMatchObject({ + providerId: 'openai', + modelId: 'gpt-5.5', + agent: 'cc', + }); + }); + + it(`最多陈列 ${UNIFIED_RECENT_MODELS_LIMIT} 条,按传入的最近顺序取`, () => { + const entries = [ + opus, + gpt, + entryOf({ modelId: 'm-3' }), + entryOf({ modelId: 'm-4' }), + entryOf({ modelId: 'm-5' }), + entryOf({ modelId: 'm-6' }), + entryOf({ modelId: 'm-7' }), + ]; + const sections = buildUnifiedListSections({ + entries, + favorites: [], + recentModels: entries.map((entry, index) => + recentOf({ providerId: entry.providerId, modelId: entry.modelId, usedAt: 100 - index }), + ), + query: '', + rail: { kind: 'recent' }, + }); + expect(sections[0].kind).toBe('recent'); + expect(sections[0].rows.map((row) => row.entry.modelId)).toEqual([ + 'claude-opus-5', + 'gpt-5.5', + 'm-3', + 'm-4', + 'm-5', + ]); + }); + + it('不可路由的条目跳过,并从更早的历史里补足', () => { + const sections = buildUnifiedListSections({ + entries: [opus, gpt], + favorites: [], + // 最新三条里两条已下架 → 展示仍然是最近可用的 2 条,不多也不少。 + recentModels: [ + recentOf({ modelId: 'gone-1', usedAt: 300 }), + recentOf({ modelId: 'gone-2', usedAt: 200 }), + recentOf({ providerId: 'openai', modelId: 'gpt-5.5', usedAt: 100 }), + recentOf({ usedAt: 50 }), + ], + query: '', + rail: { kind: 'recent' }, + }); + expect(sections[0].kind).toBe('recent'); + expect(sections[0].rows.map((row) => row.entry.modelId)).toEqual([ + 'gpt-5.5', + 'claude-opus-5', + ]); + }); + + it('老数据存的 wire id 也能解析回行身份', () => { + const bridged = entryOf({ + providerId: 'openai', + modelId: 'gpt-5.6', + displayName: 'GPT-5.6', + candidates: ['claude-code'], + capabilities: { + 'claude-code': capability('claude-code', { wireModelId: 'chatgpt/gpt-5.6' }), + }, + }); + const sections = buildUnifiedListSections({ + entries: [bridged], + favorites: [], + recentModels: [recentOf({ providerId: 'openai', modelId: 'chatgpt/gpt-5.6' })], + query: '', + rail: { kind: 'recent' }, + }); + expect(sections[0].rows[0].entry.modelId).toBe('gpt-5.6'); + expect(sections[0].rows[0].anchor).toEqual({ + kind: 'recent', + key: expect.any(String), + providerId: 'openai', + modelId: 'gpt-5.6', + }); + }); + + it('搜索词生效时回落「全部」视图(rail 被搜索接管,最近不再是当前视图)', () => { + const sections = buildUnifiedListSections({ + entries: [opus, gpt], + favorites: [], + recentModels: [recentOf({ usedAt: 200 }), recentOf({ providerId: 'openai', modelId: 'gpt-5.5', usedAt: 100 })], + query: 'gpt', + rail: { kind: 'recent' }, + }); + expect(sections.some((section) => section.kind === 'recent')).toBe(false); + expect(sections.flatMap((section) => section.rows.map((row) => row.entry.modelId))).toEqual([ + 'gpt-5.5', + ]); + }); + + it('rail = 收藏时不出最近区(收藏轨只陈列收藏)', () => { + const sections = buildUnifiedListSections({ + entries: [opus], + favorites: [favoriteOf()], + recentModels: [recentOf()], + query: '', + rail: { kind: 'favorites' }, + }); + expect(sections.map((section) => section.kind)).toEqual(['favorites']); + }); + + it('空列表 / 全部不可路由时最近视图为空', () => { + expect( + buildUnifiedListSections({ + entries: [opus], + favorites: [], + recentModels: [], + query: '', + rail: { kind: 'recent' }, + }), + ).toEqual([]); + expect( + buildUnifiedListSections({ + entries: [opus], + favorites: [], + recentModels: [recentOf({ modelId: 'gone' })], + query: '', + rail: { kind: 'recent' }, + }), + ).toEqual([]); + }); + }); }); describe('会话内形态(同引擎过滤 / pinnedEngine)', () => { @@ -504,7 +685,7 @@ describe('会话内形态(同引擎过滤 / pinnedEngine)', () => { const engineOfRow = ( overrides: Record = {}, pinnedEngine: 'cc' | 'codex' | 'pi' = 'cc', - ) => (entry: UnifiedModelEntry, favorite?: ModelFavoriteItem) => + ) => (entry: UnifiedModelEntry, favorite?: ModelConfigCopy) => favorite ? resolveFavoriteRowConfig({ entry, item: favorite }).engine : resolveUnifiedRowConfig({ @@ -719,25 +900,29 @@ describe('会话内形态(同引擎过滤 / pinnedEngine)', () => { }); describe('buildUnifiedRail', () => { - it('★ 常驻,供应商按行首次出现序排列', () => { + it('最近 / ★ 常驻,最近在上;供应商按行首次出现序排列', () => { const entries = [ entryOf({ providerId: 'xd', modelId: 'a' }), entryOf({ providerId: 'anthropic', modelId: 'b' }), entryOf({ providerId: 'xd', modelId: 'c' }), ]; - // ★ 常驻(2026-08-13 裁决:空收藏也显示,点进去看引导空态,不再按有无收藏隐藏)。 + // 两格常驻(2026-08-13 ★ 裁决 / 2026-09-16 最近裁决:空列表也显示,点进去看引导空态)。 expect(buildUnifiedRail(entries)).toEqual([ + { kind: 'recent' }, { kind: 'favorites' }, { kind: 'all' }, { kind: 'provider', providerId: 'xd' }, { kind: 'provider', providerId: 'anthropic' }, ]); - expect(buildUnifiedRail(entries)[0]).toEqual({ kind: 'favorites' }); + // 最近在收藏之上(2026-09-16 实测反馈)。 + expect(buildUnifiedRail(entries)[0]).toEqual({ kind: 'recent' }); + expect(buildUnifiedRail(entries)[1]).toEqual({ kind: 'favorites' }); }); it('会话内多一格「同引擎」,位置在 ★ 之下、全部之上', () => { const entries = [entryOf({ providerId: 'xd', modelId: 'a' })]; expect(buildUnifiedRail(entries, 'codex')).toEqual([ + { kind: 'recent' }, { kind: 'favorites' }, { kind: 'engine', agent: 'codex' }, { kind: 'all' }, @@ -754,6 +939,7 @@ describe('buildUnifiedRail', () => { entryOf({ providerId: 'openai', modelId: 'c' }), ]; expect(buildUnifiedRail(entries, undefined, ['openai', 'xd'])).toEqual([ + { kind: 'recent' }, { kind: 'favorites' }, { kind: 'all' }, { kind: 'provider', providerId: 'openai' }, diff --git a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx index 39d4e13799b..e3002a0bbce 100644 --- a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx @@ -9239,6 +9239,11 @@ export function ChatInput({ // Claude Code 回退冒充);草稿直接取当前引擎。 engineMarkVendor={unifiedPanelActive ? composerEngineMarkVendor : null} unifiedPanel={unifiedPanelActive} + // 「最近使用」只记**对话**入口(新任务草稿 / 会话内,本组件就是这两个),且 + // 远程不记:被控端目录的模型写进本机列表只会得到一行永远不可路由的记录。 + // deviceLinkDeviceId 为 undefined = 归属尚未解析(冷启动) —— 宁可这一帧 + // 不记,也不把远程选择写成本地记录。 + recordRecentUsage={!remoteHostId && deviceLinkDeviceId === null} // 联合列表只列**运行时已注册**的引擎(撤掉 AgentSelect 后接住它的 // hiddenVendors 门禁);未加载时不传 = 不隐藏任何引擎。会话内没有 // 跨引擎切换事务可走时锁定当前引擎(见 inSessionEngineLocked)。 diff --git a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx index 009e81a6886..63fe3def301 100644 --- a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx @@ -808,6 +808,8 @@ interface ModelSelectorProps { unifiedAgents?: readonly AgentKind[]; /** 统一面板是否只采用目录官方推荐配置,不读取个人引擎偏好与收藏配置。 */ unifiedSelectionPolicy?: UnifiedModelPanelProps['selectionPolicy']; + /** 语义同 ModelSelectorContentProps.recordRecentUsage(选中成功后记入「最近使用」)。 */ + recordRecentUsage?: boolean; /** * composer pill 尾部的**引擎小标**(model-selector-unified §1.1)。 * @@ -973,6 +975,13 @@ interface ModelSelectorContentProps { */ unifiedAgents?: readonly AgentKind[]; unifiedSelectionPolicy?: UnifiedModelPanelProps['selectionPolicy']; + /** + * 选择真的应用成功后,把该模型记进「最近使用」(见 state/recentModels 的语义边界)。 + * **默认关**:统一面板被对话之外的入口共用(定时任务 / IM 默认 / Bot / Hook / + * Worker / 子代理 / 设置页),那些选择是配置动作;只有对话侧真正的两个入口 + * (ChatInput 的新任务草稿与会话内)开启,且 SSH / device-link 远程不开启。 + */ + recordRecentUsage?: boolean; /** * 统一面板里被选中的**收藏锚点** uid(规格 §1.5:选中的是那一条收藏副本,不是模型本体)。 * 由调用方持有(草稿层),因为它与 (来源, 模型) 一样属于「当前选了什么」这份状态。 @@ -1170,6 +1179,7 @@ function ModelSelectorContentView({ sessionEngineFilter, unifiedAgents: requestedUnifiedAgents, unifiedSelectionPolicy = 'personalized', + recordRecentUsage = false, selectedFavoriteUid = null, onSessionFavoriteAnchorChange, onUnifiedSelect, @@ -3241,6 +3251,7 @@ function ModelSelectorContentView({ onPaymentRequired={showPaymentRequired} configurationEnabled={configurationEnabled} selectionPolicy={unifiedSelectionPolicy} + recordRecentUsage={recordRecentUsage} isRouteDisabled={(providerId, id, rowAgent) => providersOverride ? false : modelDisabledOf(providers.find((provider) => provider.id === providerId) ?? null, id, rowAgent)} {...(panelSessionEngineFilter ? { sessionEngineFilter: panelSessionEngineFilter } : {})} {...(followSession ? { followSession: { @@ -3626,6 +3637,7 @@ export function ModelSelector({ unifiedAgents, unifiedSelectionPolicy = 'personalized', engineMarkVendor = null, + recordRecentUsage = false, selectedFavoriteUid = null, onSessionFavoriteAnchorChange, onUnifiedSelect, @@ -4441,6 +4453,7 @@ export function ModelSelector({ sessionEngineFilter={contentSessionEngineFilter} unifiedAgents={unifiedAgents} unifiedSelectionPolicy={unifiedSelectionPolicy} + recordRecentUsage={recordRecentUsage} selectedFavoriteUid={selectedFavoriteUid} onSessionFavoriteAnchorChange={onSessionFavoriteAnchorChange} onUnifiedSelect={onUnifiedSelect} diff --git a/apps/desktop/src/renderer/components/new-chat/UnifiedModelPanel.tsx b/apps/desktop/src/renderer/components/new-chat/UnifiedModelPanel.tsx index af9462dde15..7feb1ff7262 100644 --- a/apps/desktop/src/renderer/components/new-chat/UnifiedModelPanel.tsx +++ b/apps/desktop/src/renderer/components/new-chat/UnifiedModelPanel.tsx @@ -16,9 +16,11 @@ import { cn } from '@/lib/utils'; import { modelPriceDiscountLabelValues, type ModelPricePresentation } from '@/lib/modelPriceFormat'; import type { Effort } from '@/lib/userPreferences.types'; import { getModelEngineOverride, useModelEnginePrefsVersion } from '@/state/modelEnginePrefs'; +import { modelConfigCopyIdentity, type ModelConfigCopy } from '@/state/modelConfigCopy'; import { useModelFavorites, type ModelFavoriteItem } from '@/state/modelFavorites'; import { useProviderModelMemoryVersion } from '@/state/providerModelMemory'; import { useAgentDeviceModelMemoryVersion } from '@/state/agentDeviceModelMemory'; +import { useRecentModels, type RecentModelItem } from '@/state/recentModels'; import { flashScrollbar } from '@/lib/scrollbarAutoHide'; import { MORPH_CONTENT_RESIZE_EVENT } from '@/components/ui/morph-popover'; @@ -73,6 +75,7 @@ const RAIL_ALL: UnifiedRailFilter = { kind: 'all' }; /** 定宽 sizer 的行不接交互。 */ const noop = (): void => {}; const NO_FAVORITES: readonly ModelFavoriteItem[] = []; +const NO_RECENT: readonly RecentModelItem[] = []; export interface UnifiedSelectedRow { /** 该行**生效**引擎(推荐 ⊕ override ⊕ 会话内 pinnedEngine ⊕ 收藏副本)。 */ @@ -143,6 +146,14 @@ export interface UnifiedModelPanelProps { onPaymentRequired?: () => void; /** false = 只选模型,不出配置浮层(设置类入口的 configurationEnabled)。 */ configurationEnabled?: boolean; + /** + * 选择真的应用成功后,把该模型记进「最近使用」(见 state/recentModels 的语义边界)。 + * **默认关**:统一面板被对话之外的入口(定时任务 / IM 默认 / Bot / Hook / Worker / + * 子代理 / 设置页)共用,那些入口的选择是配置动作,不是「用这个模型跑过活」。只有 + * 对话侧的两个入口(ChatInput 的新任务草稿与会话内)显式开启,且远程(SSH / + * device-link)不传 —— 被控端目录的模型写进本机列表只会得到一行永远不可路由的记录。 + */ + recordRecentUsage?: boolean; isRouteDisabled?: (providerId: string, modelId: string, agent: AgentKind) => boolean; /** * official = 模型优先的受限入口。忽略全局引擎偏好、模型记忆和收藏配置, @@ -326,6 +337,7 @@ export function UnifiedModelPanel({ paymentRequiredUnlockLabel, onPaymentRequired, configurationEnabled = true, + recordRecentUsage = false, selectionPolicy = 'personalized', deviceId, isRouteDisabled, @@ -345,6 +357,10 @@ export function UnifiedModelPanel({ const storedFavorites = useModelFavorites(); const remoteFavorites = useRemoteModelFavorites(deviceId); const favorites = selectionPolicy === 'official' ? NO_FAVORITES : deviceId ? remoteFavorites.items : storedFavorites; + const storedRecentModels = useRecentModels(); + // official 入口(设置页 / create-agent)刻意不展示「最近」:那里的选择动作不记录(见 + // `recordRecentUsage`)也不该被个人流水影响 —— 与收藏区同一条策略门。 + const recentModels = selectionPolicy === 'official' ? NO_RECENT : storedRecentModels; // 引擎 override / 深度 / Fast 三份 store 的版本号:任一变化都要重算行三元组与浮层 // (其它窗口的 storage 事件、device-link 推送同样经这两个版本号进来)。远程 Agent 的档位记忆 // 是另一份 store,版本号并进 memoryVersion。 @@ -535,14 +551,15 @@ export function UnifiedModelPanel({ const configOf = useCallback( ( entry: UnifiedModelEntry, - favorite?: ModelFavoriteItem, + /** 配置副本条目(收藏 / 最近)—— 只读它自存的引擎 / 深度 / Fast。 */ + copy?: ModelConfigCopy, // 定宽 sizer 量的是「全部」视图,必须按那条轨解析,不能继承当前 effectiveRail // (否则兼容行被钉成 π 后量到更窄的三元组,切「全部」再弹宽)。 railForConfig: UnifiedRailFilter = effectiveRail, ): UnifiedRowConfig => { - // 收藏条目只读它自己存的副本(规格 §1.5),不掺模型默认与记忆。 - if (favorite) { - return resolveFavoriteRowConfig({ entry, item: favorite, agentFastModeCapable }); + // 收藏 / 最近条目只读它自己存的副本(规格 §1.5),不掺模型默认与记忆。 + if (copy) { + return resolveFavoriteRowConfig({ entry, item: copy, agentFastModeCapable }); } // 两个版本号只作重算触发器:store 是模块级单例,值本身不进依赖。 void enginePrefsVersion; @@ -640,7 +657,7 @@ export function UnifiedModelPanel({ * configOf 把显示/点选钉在轨引擎,这里不钉 —— 否则排序键全变成当前轨,优先/兼容段塌掉。 */ const effectiveEngineOf = useCallback( - (entry: UnifiedModelEntry, favorite?: ModelFavoriteItem): UnifiedEngine => { + (entry: UnifiedModelEntry, favorite?: ModelConfigCopy): UnifiedEngine => { if (favorite) { // 收藏行引擎 = 副本自存引擎(掉出候选回落推荐)。不传 agentFastModeCapable: // 它只影响 fast/fastCapable,不影响 engine。 @@ -677,6 +694,7 @@ export function UnifiedModelPanel({ buildUnifiedListSections({ entries, favorites, + recentModels, query, matchesQuery: (entry, q) => matchesModelName({ id: entry.modelId, displayName: entry.displayName, description: entry.description, @@ -688,7 +706,7 @@ export function UnifiedModelPanel({ ? { recommendation: { agent: liveEngineAgent, ...selected } } : {}), }), - [entries, favorites, query, effectiveRail, effectiveEngineOf, providerOrder, t, scope, liveEngineAgent, selected.modelId, selected.providerId], + [entries, favorites, recentModels, query, effectiveRail, effectiveEngineOf, providerOrder, t, scope, liveEngineAgent, selected.modelId, selected.providerId], ); // 打开或切视图时,把模型本体的当前行对齐到可视高度 35% 处;收藏可以滚出顶部。 @@ -821,13 +839,40 @@ export function UnifiedModelPanel({ const isSelectedRow = useCallback( (anchor: UnifiedAnchor, entry: UnifiedModelEntry): boolean => { if (anchor.kind === 'fav') return false; + // 最近行 = 配置副本:选中态按**整份配置**核对(同模型不同档位各占一行,不能都打上 + // 选中底色)。与收藏锚点同一条判据,只是没有 uid、以副本身份为锚点。 + if (anchor.kind === 'recent') { + const item = recentModels.find( + (candidate) => modelConfigCopyIdentity(candidate) === anchor.key, + ); + return ( + !!item && + favoriteMatchesSelection({ + entry, + item, + selected, + agent: liveEngineAgent, + effort: selectedEffort, + fast: fastMode, + agentFastModeCapable, + }) + ); + } // 会话 / 草稿存的是 wire id;按「行 id 或任一引擎 wire id 命中」解析(合并行契约)。 return ( entryMatchesModelId(entry, selected.modelId) && (selected.providerId === null || selected.providerId === anchor.providerId) ); }, - [selected.modelId, selected.providerId], + [ + agentFastModeCapable, + fastMode, + liveEngineAgent, + recentModels, + selected.modelId, + selected.providerId, + selectedEffort, + ], ); /** ☆ 点亮 0.7s 后恢复(规格 §1.5:源头行不持有收藏态,只给一次动作反馈)。 */ @@ -857,6 +902,7 @@ export function UnifiedModelPanel({ } = useUnifiedRowActions({ favoriteStore: deviceId ? remoteFavorites.store : undefined, interactionDisabled, + recordRecentUsage, isLiveRow, // 两笔实时写入(深度 + Fast)里第二笔失败时回滚第一笔用的原值,以及收藏 live 判定 // 要比的实时深度 / Fast —— 与 configOf 里「选中行读 live 值」取的是同一个格子。 @@ -885,9 +931,9 @@ export function UnifiedModelPanel({ // 「按这份收藏副本解析该行」——与收藏行渲染(configOf 的 favorite 分支)走**同一个** // resolveFavoriteRowConfig:编辑选中收藏的引擎时,新引擎的 wire id / 档位回落 / Fast // 能力必须与编辑完之后行上显示的那一份逐字一致(详见 useUnifiedRowActions)。 - // uid 在这里无意义(解析只看配置),给空串占位。 + // uid 在这里无意义(解析只看配置),配置副本类型直接兼容收藏条目。 resolveFavoriteConfig: (entry, favorite) => - resolveFavoriteRowConfig({ entry, item: { uid: '', ...favorite }, agentFastModeCapable }), + resolveFavoriteRowConfig({ entry, item: favorite, agentFastModeCapable }), // 「该行没有收藏语境时的默认配置」:引擎 = 推荐 ⊕ 用户 override ⊕ 会话 pinned(与 // configOf 的模型行分支同一套合成,少给一路就会算出一个用户从没见过的引擎), // **刻意不传 memoryEffort / memoryFast** —— 回落的是「该模型的默认」,不是用户上次在 @@ -948,13 +994,15 @@ export function UnifiedModelPanel({ }; /** - * 小节标题:收藏 / 供应商分组(Chris 2026-08-13 裁决:按供应商,不按模型家族; - * 组名与模型设置页同一套 providerLabel)。 + * 小节标题:收藏 / 最近 / 推荐 / 供应商分组(Chris 2026-08-13 裁决:按供应商, + * 不按模型家族;组名与模型设置页同一套 providerLabel)。 */ const sectionLabel = (section: (typeof sections)[number]): string => section.kind === 'favorites' ? t('newChat.modelSelector.unified.favoritesGroup') - : section.kind === 'recommended' + : section.kind === 'recent' + ? t('newChat.modelSelector.unified.recentGroup') + : section.kind === 'recommended' ? t('newChat.modelSelector.unified.recommended') : section.group ? providerLabel(section.group.providerId) @@ -963,6 +1011,16 @@ export function UnifiedModelPanel({ const rows = sections.flatMap((section) => section.rows); const hasRows = rows.length > 0; + /** + * 行 L2 显示 provider 来源而不是模型描述(2026-09-16 实测裁决)。 + * 收藏 / 最近 / 全部三个视图一致:它们都是「跨来源的快捷清单」,行上必须说清 + * 这一条是哪家供的(同名模型跨来源价格不同);单来源视图(供应商轨)不需要重复来源。 + */ + const showsSourceRow = + effectiveRail.kind === 'all' || + effectiveRail.kind === 'recent' || + effectiveRail.kind === 'favorites'; + /** * 行内价格的派生(设计稿 v4 定稿 F 样式):付费行显示 $ 档串,折扣行亮段按 * 折后价比例填充并尾随 ↓X%;限免显示淡染小徽标;无报价不渲染节点。 @@ -1033,13 +1091,14 @@ export function UnifiedModelPanel({ ? buildUnifiedListSections({ entries, favorites, + recentModels, query: '', rail: RAIL_ALL, effectiveEngineOf, providerOrder, }) : [], - [widthSizerActive, entries, favorites, effectiveEngineOf, providerOrder], + [widthSizerActive, entries, favorites, recentModels, effectiveEngineOf, providerOrder], ); const panelContent = ( @@ -1162,10 +1221,12 @@ export function UnifiedModelPanel({ ) ) : !hasRows ? (
- {/* ★ 视图的空态是引导语,不是「没有匹配」(设计稿 favEmpty;★ 常驻后必经)。 */} - {effectiveRail.kind === 'favorites' && !query.trim() + {/* 最近 / ★ 视图的空态是引导语,不是「没有匹配」(设计稿 favEmpty;两格常驻后必经)。 */} + {!query.trim() && effectiveRail.kind === 'favorites' ? t('newChat.modelSelector.unified.favoritesEmpty') - : t('newChat.modelSelector.search.noResults')} + : !query.trim() && effectiveRail.kind === 'recent' + ? t('newChat.modelSelector.unified.recentEmpty') + : t('newChat.modelSelector.search.noResults')}
) : ( sections.map((section) => ( @@ -1186,27 +1247,59 @@ export function UnifiedModelPanel({ {section.rows.map((row) => { const config = withOptimisticConfig( row.anchor, - configOf(row.entry, row.favorite), + configOf(row.entry, row.favorite ?? row.recent), optimisticConfig, ); const key = anchorKey(row.anchor); const priceDisplay = priceDisplayOf(row.entry, config); + /** + * 最近行按**解析后的整份配置**匹配收藏(2026-09-16 裁决:最近也是配置副本): + * 同一来源 + 同一模型下,引擎 / 深度 / Fast 解析后一致就点亮星标; + * 点星把它存进收藏 / 从收藏里移除。 + * 前两维(来源 + 模型)必须先圈定(2026-09-16 review P0):只看三元组的话, + * 另一个型号的收藏只要引擎 / 深度 / Fast 巧合一致就会被误命中,点星删掉的是 + * 用户根本没点的那条收藏。三元组用**解析值**而不是存储字面量比:两边都可能是 + * 「跟随推荐」的缺省字段 —— 按字面量会比出「一边有 effort、一边没有」的假不匹配。 + */ + const matchingFavorite = row.recent + ? favorites.find((favorite) => { + if (favorite.providerId !== row.entry.providerId) return false; + if (!entryMatchesModelId(row.entry, favorite.modelId)) return false; + const favoriteConfig = resolveFavoriteRowConfig({ + entry: row.entry, + item: favorite, + agentFastModeCapable, + }); + return ( + favoriteConfig.engine === config.engine && + favoriteConfig.effort === config.effort && + favoriteConfig.fast === config.fast + ); + }) + : undefined; + const recentRow = !!row.recent; + // 最近行本身不持有收藏态:命中的收藏就是它的星标与**选中锚点** + // (点击=按这份副本配置应用,同收藏副本语义 —— 带上 uid,锚点/星标才一致)。 + const starredFavorite = row.favorite ?? matchingFavorite; return ( selectRow(row.anchor, config, row.favorite)} + onReveal={recentRow ? noop : revealFlyout} + onRevealForKeyboard={recentRow ? noop : revealFlyoutForKeyboard} + onSelect={() => + // 星标行的点击与收藏行同构:带上命中的那条收藏,选中态才不只停在「配置像」上。 + selectRow(row.anchor, config, starredFavorite, row.entry) + } onStar={ selectionPolicy === 'personalized' - ? () => - row.favorite - ? removeFavorite(row.anchor, row.entry) - : addFavorite(row.anchor, config) + ? recentRow + ? () => + matchingFavorite + ? removeFavorite( + { + kind: 'fav', + uid: matchingFavorite.uid, + providerId: matchingFavorite.providerId, + modelId: matchingFavorite.modelId, + }, + row.entry, + ) + : addFavorite(row.anchor, config) + : () => + row.favorite + ? removeFavorite(row.anchor, row.entry) + : addFavorite(row.anchor, config) : undefined } /> @@ -1262,7 +1371,7 @@ export function UnifiedModelPanel({ {sectionLabel(section)} {section.rows.map((row) => { - const config = configOf(row.entry, row.favorite, RAIL_ALL); + const config = configOf(row.entry, row.favorite ?? row.recent, RAIL_ALL); const priceDisplay = priceDisplayOf(row.entry, config); return ( {separatorBefore && ( @@ -126,7 +128,11 @@ export function UnifiedModelRail({ provider={providerUsage ? provider : undefined} usageDeviceId={usageDeviceId} > - {item.kind === 'favorites' ? ( + {item.kind === 'recent' ? ( + // 最近格用 History(时钟 + 回拨箭头)表达「用过的时间序」,与 ★ 的「钉住」 + // 语义区分;与其它非激活格同灰,hover 提亮,不抢视线。 + + ) : item.kind === 'favorites' ? ( // ☆ 未激活与其它格同灰(hover 提亮)—— 常亮金色会在没进收藏视图时也 // 抢视线(2026-08-14 实机自查);激活时整格反色 + 实心星跟随 currentColor。 diff --git a/apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx b/apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx index 38e6de4b3b5..66eba24aa27 100644 --- a/apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx +++ b/apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx @@ -48,6 +48,7 @@ export function UnifiedModelRow({ onRevealForKeyboard, priceDisplay, configurationEnabled = true, + customizeEnabled = true, paymentRequired = false, paymentRequiredLabel, paymentRequiredUnlockLabel, @@ -75,6 +76,13 @@ export function UnifiedModelRow({ /** 行内价格展示;不传 = 无报价。字段语义见 `UnifiedRowPriceDisplay`。 */ priceDisplay?: UnifiedRowPriceDisplay; configurationEnabled?: boolean; + /** + * 行是否提供「自定义」入口(配置浮层)。false 时隐藏按钮、并在调用方关掉 reveal 回调; + * 与 `configurationEnabled` 的区别:那个还会把三元组里的档位 / Fast 显示一并收掉 + * (设置类入口不展示这些维度),而最近视图是**只读的配置副本行** —— 档位照常显示, + * 只是不在这里编辑(最近是流水账,不提供手工改配置的入口)。 + */ + customizeEnabled?: boolean; /** 付费锁定行保留在原位置,可聚焦但不能选中、收藏或打开配置。 */ paymentRequired?: boolean; paymentRequiredLabel?: string; @@ -88,7 +96,8 @@ export function UnifiedModelRow({ const priceSymbol = priceDisplay?.symbol ?? '$'; const engineOption = agentOptionOf(config.engine); const openConfig = (element: HTMLElement, toggle = false) => { - if (!paymentRequired) onReveal(anchor, element, toggle); + if (paymentRequired || !customizeEnabled || !configurationEnabled) return; + onReveal(anchor, element, toggle); }; const tripleTitle = `${engineOption.label}${ configurationEnabled && config.effort ? ` · ${effortLabelOf(config.agent, config.effort)}` : '' @@ -108,7 +117,10 @@ export function UnifiedModelRow({ 'aria-disabled': interactionDisabled ? true : undefined, 'aria-label': paymentRequiredActionLabel, // ← 开配置浮层是这一行唯一的键盘入口,不声明就只有摸索得到(读屏用户尤甚)。 - 'aria-keyshortcuts': paymentRequired ? undefined : 'ArrowLeft', + // 没有浮层入口的行不再宣告这个快捷键:最近行是只读配置副本(customizeEnabled=false), + // 设置类入口不出配置浮层(configurationEnabled=false)—— 两者按过去都什么都不发生。 + 'aria-keyshortcuts': + paymentRequired || !customizeEnabled || !configurationEnabled ? undefined : 'ArrowLeft', tabIndex: interactionDisabled ? -1 : 0, 'data-model-selected': selected ? ('true' as const) : undefined, // Shared menu row: the panel's glide highlight; the chosen row keeps its static fill (below). @@ -116,7 +128,9 @@ export function UnifiedModelRow({ 'data-state': active ? 'open' : selected ? 'checked' : undefined, 'data-unified-anchor': anchorKey(anchor), onContextMenu: (event: ReactMouseEvent) => { - if (interactionDisabled || paymentRequired) return; + // 没有配置入口的行(付费行 / 最近行 / 设置类入口)**不抢**右键:不 preventDefault, + // 把原生菜单留给用户,否则右键变成「什么都不发生、原生菜单也被吃掉」(2026-09-16 review P1)。 + if (!customizeEnabled || !configurationEnabled || interactionDisabled || paymentRequired) return; event.preventDefault(); openConfig(event.currentTarget); }, @@ -135,6 +149,7 @@ export function UnifiedModelRow({ return; } if (event.key === 'ArrowLeft') { + if (!customizeEnabled || !configurationEnabled) return; event.preventDefault(); onRevealForKeyboard(anchor, event.currentTarget); return; @@ -269,7 +284,7 @@ export function UnifiedModelRow({ /> )} {starButton} - {configurationEnabled && customizeButton} + {configurationEnabled && customizeEnabled && customizeButton} {/* 常驻三元组:引擎图标 + 推理强度 + ⚡。所有行同构,自定义行整组提亮一档。 设计稿 .l1-right:margin-left auto 把右侧簇推到最右,左侧簇贴名字排。 */} diff --git a/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts b/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts index 2925e5600c7..ee3094dc87e 100644 --- a/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts +++ b/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts @@ -1,6 +1,6 @@ /** * unifiedModelSelection —— 统一模型选择器(模型优先)面板的**纯逻辑层**:行生效配置合成、 - * 收藏/分组陈列、rail 派生、配置浮层定位。规格见 + * 收藏 / 最近 / 分组陈列、rail 派生、配置浮层定位。规格见 * `docs/product-rules/model-selector-unified.md` §1.2 / §1.3 / §1.5 / §2。 * * 为什么单独一层:M3 的行三元组(引擎图标 + 推理强度 + Fast)与 M4 浮层里的每一个控件, @@ -23,7 +23,9 @@ import type { AgentKind } from '@/hooks/useAgentCapabilities'; import type { SelectableVendor } from '@/lib/agentVendors'; import type { Effort } from '@/lib/userPreferences.types'; import { applyProviderOrderIds } from '../../../shared/providerOrder'; +import { modelConfigCopyIdentity, type ModelConfigCopy } from '@/state/modelConfigCopy'; import type { ModelFavoriteItem } from '@/state/modelFavorites'; +import type { RecentModelItem } from '@/state/recentModels'; /** 引擎在**选择器 / 草稿链路**里的口径(vendor);catalog / capabilities 侧是 AgentKind。 */ export type UnifiedEngine = SelectableVendor; @@ -41,16 +43,22 @@ export function engineOfAgentKind(agent: AgentKind): UnifiedEngine { /** * 行 / 浮层的锚点(规格 §1.5):模型行按 (来源, 模型) 定位,收藏条目按**独立 uid** 定位。 * 同模型的多条收藏互不牵连,靠的就是这个 uid —— 选中 / hover / 浮层绑定 / 删除全走锚点。 + * + * 最近条目按**副本身份**(`key`,与 store 的去重身份同一份)定位:同一模型的不同配置 + * (引擎 / 深度 / Fast)各占一行,选中态只能对着其中一行。 */ export type UnifiedAnchor = | { kind: 'model'; providerId: string; modelId: string } - | { kind: 'fav'; uid: string; providerId: string; modelId: string }; + | { kind: 'fav'; uid: string; providerId: string; modelId: string } + | { kind: 'recent'; key: string; providerId: string; modelId: string }; /** 锚点的字符串键(React key / DOM data 属性 / 相等比较)。 */ export function anchorKey(anchor: UnifiedAnchor): string { return anchor.kind === 'fav' ? `fav::${anchor.uid}` - : `model::${anchor.providerId}::${anchor.modelId}`; + : anchor.kind === 'recent' + ? `recent::${anchor.key}` + : `model::${anchor.providerId}::${anchor.modelId}`; } export function sameAnchor(a: UnifiedAnchor | null, b: UnifiedAnchor | null): boolean { @@ -230,7 +238,8 @@ export function resolveUnifiedRowConfig(args: ResolveRowConfigArgs): UnifiedRowC */ export function resolveFavoriteRowConfig(args: { entry: UnifiedModelEntry; - item: ModelFavoriteItem; + /** 配置副本(收藏条目或最近记录)—— *只* 读它自存的引擎 / 深度 / Fast。 */ + item: ModelConfigCopy; agentFastModeCapable?: (agent: AgentKind) => boolean; }): UnifiedRowConfig { const { entry, item, agentFastModeCapable } = args; @@ -265,7 +274,7 @@ export function resolveFavoriteRowConfig(args: { */ export function favoriteMatchesSelection(args: { entry: UnifiedModelEntry; - item: ModelFavoriteItem; + item: ModelConfigCopy; selected: { providerId: string | null; modelId: string }; agent: AgentKind | null | undefined; effort: Effort | undefined; @@ -307,6 +316,7 @@ export function isRecommendedFavoriteConfig( * `engine` 格只在**会话内**出现(规格 §1.6:图标 = 当前会话引擎,默认选中)。 */ export type UnifiedRailItem = + | { kind: 'recent' } | { kind: 'favorites' } | { kind: 'engine'; agent: AgentKind } | { kind: 'all' } @@ -341,14 +351,17 @@ export function railItemKey(item: UnifiedRailItem): string { } /** - * rail 项派生:★收藏(**常驻**) → 同引擎(仅会话内) → 全部 → 各来源供应商 + * rail 项派生:最近 → ★收藏(**两者常驻**) → 同引擎(仅会话内) → 全部 → 各来源供应商 * (按行首次出现序,即联合列表的引擎优先序 × catalog 序)。 * + * 「最近」在 ★ 之上(2026-09-16 裁决):它是面板里最常用的回顾入口,常驻与 ★ 同理 —— + * 空列表时点进去看引导空态,不按有无记录隐藏。 + * * 「同引擎」格刻意排在 ★ 之下、全部之上(规格 §1.6):它是会话内的**默认视图**, * 但收藏仍是用户自己钉的东西,优先级更高。 * - * 刻意**不收** favorites:★ 常驻是裁决(见函数体注释),格位与收藏条目多少无关 —— - * 收着一个不看的参数只会让调用方以为「传了它就会影响 rail」。 + * 刻意**不收** favorites / recent 数据:两格常驻是裁决(见函数体注释),格位与条目多少 + * 无关 —— 收着不看的参数只会让调用方以为「传了它就会影响 rail」。 */ export function buildUnifiedRail( entries: readonly UnifiedModelEntry[], @@ -356,8 +369,9 @@ export function buildUnifiedRail( providerOrder?: readonly string[], ): UnifiedRailItem[] { const items: UnifiedRailItem[] = []; - // ★ 常驻(设计稿 renderRail:collection 永远在第一格,空收藏点进去看空态引导)—— - // 只在有收藏时出现会让功能不可发现(Chris 2026-08-13 实测:「分类栏直接砍了?」)。 + // 最近 / ★ 常驻(设计稿 renderRail:collection 永远在第一格,空收藏点进去看空态引导)—— + // 只在有数据时出现会让功能不可发现(Chris 2026-08-13 实测:「分类栏直接砍了?」)。 + items.push({ kind: 'recent' }); items.push({ kind: 'favorites' }); if (sessionAgent) items.push({ kind: 'engine', agent: sessionAgent }); items.push({ kind: 'all' }); @@ -379,13 +393,15 @@ export function buildUnifiedRail( export interface UnifiedListRow { anchor: UnifiedAnchor; entry: UnifiedModelEntry; - /** 收藏区行才有;模型行为 undefined。 */ + /** 收藏区行才有;模型行 / 最近行没有。 */ favorite?: ModelFavoriteItem; + /** 最近视图行才有(配置副本 + usedAt);其余行没有。 */ + recent?: RecentModelItem; } export interface UnifiedListSection { key: string; - kind: 'favorites' | 'recommended' | 'group'; + kind: 'favorites' | 'recent' | 'recommended' | 'group'; /** * 分组小节的口径 —— **按供应商,不按模型家族**(Chris 2026-08-13 实测裁决:供应商决定 * 价格,同名模型跨来源混排会让用户没法选)。每个供应商各自成组,标题用 @@ -414,9 +430,18 @@ function entryKeyOf(providerId: string, modelId: string): string { return `${providerId} ${modelId}`; } +/** + * 面板「最近」区最多陈列几条(产品口径:最近使用的 5 个模型)。 + * + * 刻意**不**从 recentModels store import:纯逻辑层只 import store 的**类型** + * (`import type` 会被编译期抹掉),拖进一个带 React 的模块会破坏「零 IO、可脱离 jsdom 单测」 + * 的边界(见文件头三条边界)。 + */ +export const UNIFIED_RECENT_MODELS_LIMIT = 5; + type EnginePreferenceResolver = ( entry: UnifiedModelEntry, - favorite?: ModelFavoriteItem, + favorite?: ModelConfigCopy, ) => UnifiedEngine; /** @@ -479,7 +504,9 @@ function arrangeEngineRailClusters( } /** - * 面板列表:**收藏区置顶** → **按供应商分组**。 + * 面板列表(常规视图):**收藏区** → **推荐区(仅会话)** → **按供应商分组**。 + * 最近**不**并入常规列表 —— 它是侧栏「最近」格单独的筛选视图(2026-09-16 实测裁决: + * 与收藏上下叠放会产生视觉歧义)。 * * 没有「默认」小节(Chris 2026-08-16 裁决:去掉默认小节,简单一点)—— 服务端的默认 * 推荐改以**种子收藏**交付(见 modelFavorites.seedDefaultFavorite):gateway 用户的 @@ -502,6 +529,12 @@ function arrangeEngineRailClusters( export function buildUnifiedListSections(args: { entries: readonly UnifiedModelEntry[]; favorites: readonly ModelFavoriteItem[]; + /** + * 最近使用的模型(store 的原样快照,已按 usedAt 倒序)。只服务 `rail.kind === 'recent'` + * 的独立视图;常规视图(全部 / 供应商 / 同引擎)不陈列最近区(2026-09-16 实测裁决), + * 因此与收藏、推荐之间的重叠在 UI 上不可见,无需去重。 + */ + recentModels?: readonly RecentModelItem[] | undefined; query: string; matchesQuery?: (entry: UnifiedModelEntry, query: string) => boolean; rail: UnifiedRailFilter; @@ -510,7 +543,7 @@ export function buildUnifiedListSections(args: { * (默认 / 用户选过的本引擎在前,仅兼容的在后)以及收藏区过滤。缺省时按原生底座回落。 * override / pinned / forceEngine 的合成结果由调用方注入,这里不 import store。 */ - effectiveEngineOf?: (entry: UnifiedModelEntry, favorite?: ModelFavoriteItem) => UnifiedEngine; + effectiveEngineOf?: (entry: UnifiedModelEntry, favorite?: ModelConfigCopy) => UnifiedEngine; /** 供应商组间显示顺序(设置页拖动序);缺省 = 入参首见序。 */ providerOrder?: readonly string[]; recommendation?: { agent: AgentKind; providerId: string | null; modelId: string }; @@ -522,21 +555,28 @@ export function buildUnifiedListSections(args: { const byKey = new Map(); for (const entry of entries) byKey.set(entryKeyOf(entry.providerId, entry.modelId), entry); + /** + * 把一份「存下来的模型身份」解析回本轮目录里的一行(收藏与最近共用)。 + * + * 老数据可能存的是某个引擎的 wire id(合并行之前行身份就是 wire id):先按归一化 id + * 精确命中,失配再按「任一引擎的 wire id」扫一遍 —— 否则升级后老收藏会整条消失。 + * 解析不到 = 该模型当前不可路由(来源断开 / 目录下架)→ 调用方跳过,**不删条目**: + * 连回来就该回来,静默删掉用户存过的东西是不可逆的。 + */ + const resolveStoredEntry = (providerId: string, modelId: string): UnifiedModelEntry | undefined => + byKey.get(entryKeyOf(providerId, modelId)) ?? + entries.find( + (candidate) => + candidate.providerId === providerId && entryMatchesModelId(candidate, modelId), + ); + const sections: UnifiedListSection[] = []; // ── 收藏区 ── 恒置顶,在任何 rail 视图下都显示;按供应商筛选时只留该来源的收藏。 const favRows: UnifiedListRow[] = []; for (const item of favorites) { - // 老收藏可能存的是某个引擎的 wire id(合并行之前的行身份就是 wire id):先按归一化 id - // 精确命中,失配再按「任一引擎的 wire id」扫一遍 —— 否则升级后老收藏会整条消失。 - const entry = - byKey.get(entryKeyOf(item.providerId, item.modelId)) ?? - entries.find( - (candidate) => - candidate.providerId === item.providerId && entryMatchesModelId(candidate, item.modelId), - ); - // 收藏指向的模型已不可路由(来源断开 / 目录下架)→ 本轮不显示;**不删条目**: - // 连回来就该回来,静默删掉用户存过的配置是不可逆的。 + const entry = resolveStoredEntry(item.providerId, item.modelId); + // 收藏指向的模型已不可路由(来源断开 / 目录下架)→ 本轮不显示。 if (!entry) continue; const railProviderId = railProviderFilterId(rail); if (railProviderId !== null && entry.providerId !== railProviderId) continue; @@ -565,12 +605,49 @@ export function buildUnifiedListSections(args: { favorite: item, }); } + // ★ 视图只陈列收藏:提前返回,不进最近 / 推荐 / 分组。 + if (rail.kind === 'favorites') { + if (favRows.length > 0) { + sections.push({ key: 'favorites', kind: 'favorites', rows: favRows }); + } + return sections; + } + + // ── 最近视图 ── **只服务侧栏的「最近」格**(2026-09-16 实测裁决:各供应商 / 全部视图 + // 不再陈列最近区 —— 它与收藏上下叠放会产生「这两区到底有什么不同」的视觉歧义)。 + // 因此这里不把最近并入常规列表,而是单独成页:白名单是 store 里最近的 UNIFIED_RECENT_MODELS_LIMIT + // 条可路由模型;每行拿的是记录时的**配置副本**(模型 + 引擎 + 深度 + Fast),行身份就是 + // 这份副本(同一模型不同配置各占一行,锚点 key 即副本身份,见 recentModels 文件头)。 + if (rail.kind === 'recent') { + const recentRows: UnifiedListRow[] = []; + for (const item of args.recentModels ?? []) { + if (recentRows.length >= UNIFIED_RECENT_MODELS_LIMIT) break; + const entry = resolveStoredEntry(item.providerId, item.modelId); + if (!entry) continue; + if (!matches(entry, q)) continue; + recentRows.push({ + anchor: { + kind: 'recent', + // 副本身份当锚点:同一模型的不同配置各占一行,选中态只能对着其中一行。 + key: modelConfigCopyIdentity(item), + providerId: entry.providerId, + modelId: entry.modelId, + }, + entry, + recent: item, + }); + } + if (recentRows.length > 0) { + sections.push({ key: 'recent', kind: 'recent', rows: recentRows }); + } + return sections; + } + + // ── 收藏区 ── 常规视图(全部 / 供应商 / 同引擎)里恒置顶,按供应商筛选时只留该来源。 if (favRows.length > 0) { sections.push({ key: 'favorites', kind: 'favorites', rows: favRows }); } - if (rail.kind === 'favorites') return sections; - // ── 分组区 ── // 同引擎视图的准入只有一条:候选里有当前引擎。生效引擎是排序优先级,不是隐藏条件 // (Chris 2026-08-23 纠正 08-19「不显示」裁决):默认 / 用户选过本引擎的在前,其余兼容 diff --git a/apps/desktop/src/renderer/components/new-chat/useUnifiedRowActions.ts b/apps/desktop/src/renderer/components/new-chat/useUnifiedRowActions.ts index c8a542d2db3..5e7553c6553 100644 --- a/apps/desktop/src/renderer/components/new-chat/useUnifiedRowActions.ts +++ b/apps/desktop/src/renderer/components/new-chat/useUnifiedRowActions.ts @@ -13,6 +13,8 @@ import type { FavoriteStore } from '@/state/useRemoteModelFavorites'; * 配置真的应用到正在跑的那一份**(live 状态不读记忆表,只清记忆等于只改了显示), * 跨引擎时复用与 applyEngine 同一条切换链路; * - 收藏:☆ 是单向「存一份当前生效配置的副本」,收藏行的 ☆ 才是删除; + * - 最近使用:选中**真的应用成功**才记(定时任务 / 设置等非对话入口不开,见 + * `recordRecentUsage`); * - 选中:跨引擎的那一下**不走**普通 onSelect,交给调用方的切换事务。 * * 本 hook 持有写入锁;点亮反馈的计时器留在组件里,经 `onFavoriteFlash` 回调触发。 @@ -33,6 +35,7 @@ import { type ModelFavoriteConfig, type ModelFavoriteItem, } from '@/state/modelFavorites'; +import { getRecentModelsOwner, recordRecentModel } from '@/state/recentModels'; import type { ModelMemoryAccessors } from './ModelSelector'; import type { UnifiedSelectedRow } from './UnifiedModelPanel'; @@ -101,6 +104,11 @@ export interface UnifiedRowActionsOptions { config: UnifiedSelectedRow, ) => ActionResult) | undefined; + /** + * 选择真的应用成功后,把该模型记进「最近使用」(见 state/recentModels 的语义边界)。 + * 默认关:统一面板被对话之外的入口共用,那些选择是配置动作;只有对话侧两个入口开启。 + */ + recordRecentUsage?: boolean; sessionEngineFilter?: | { currentAgent: AgentKind; @@ -237,6 +245,8 @@ export interface UnifiedRowActions { anchor: UnifiedAnchor, config: UnifiedRowConfig, favorite?: ModelFavoriteItem, + /** 该锚点指向的模型行 —— 「最近使用」记的归一化行身份从这里取(见 selectRow 实现)。 */ + entry?: UnifiedModelEntry, ) => ActionResult; } @@ -374,6 +384,7 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified onSelect, onConfigure, onSelectedFavoriteAnchorClear, + recordRecentUsage = false, sessionEngineFilter, sessionAgent, resolveEngineConfig, @@ -391,6 +402,43 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified remove: removeModelFavorite, }; + /** + * 「最近使用」的唯一记录点:选择**真的应用成功**之后才记 —— 取消确认 / 写入失败 / 抛错都 + * 不记(与收藏锚点「成功才落」同一条规则)。void 视为成功(调用方既有约定);同步 / 异步 + * 结果都接住,并把原结果原样传回去(调用方还要靠 false 决定留不留面板)。 + * + * 记的是**归一化行身份 + 那一刻生效的整份配置**(引擎 / 深度 / Fast):最近 = 配置副本, + * 与收藏同一套语义(见 recentModels 文件头),星标也按这份副本与收藏做完全匹配。 + * 缺省字段不落快照(effort 无所谓「跟随推荐」/ fast 关闭)。 + */ + const rememberRecentModel = ( + result: ActionResult, + entry: UnifiedModelEntry | undefined, + config: UnifiedRowConfig, + ): ActionResult => { + if (!recordRecentUsage || !entry) return result; + // 归属代次在**发起选择时**捕获:选择可能是异步的(跨引擎事务、远程写),回调落地时用户 + // 可能已经登出 / 切到本地模式。那种情况下绝不能把 A 的一条模型记录写进 B 的分区 + // (2026-09-16 review P1:跨账号串写)。归属变了就整条丢弃 —— 少一条快捷方式远好于串号。 + const ownerAtSelect = getRecentModelsOwner(); + const commit = (applied: void | boolean): void | boolean => { + if (applied !== false && getRecentModelsOwner() === ownerAtSelect) { + recordRecentModel({ + providerId: entry.providerId, + modelId: entry.modelId, + agent: config.engine, + ...(config.effort ? { effort: config.effort } : {}), + ...(config.fast ? { fast: true as const } : {}), + }); + } + return applied; + }; + if (result && typeof result === 'object' && 'then' in result) { + return Promise.resolve(result).then(commit); + } + return commit(result); + }; + // ── 「把一份配置真的应用到正在跑的那一份上」的三条链路 ───────────────────── // 恢复推荐(§1.4)与「删除当前选中的收藏」(§1.5)是同一件事的两个入口:都要把行回落到 // 默认 / 推荐配置。三条链路抽在这里,两个入口共用 —— 各写一遍必然漂移成「恢复推荐会 @@ -828,7 +876,8 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified // device-link 镜像、IM /model)。这里写归一化 id 会造出一份谁也读不到的影子记录, // 同时污染那张表。anchor.modelId 只是行身份,不是可以发出去的东西。 if (!modelMemory && onConfigure) { - return selectRow(anchor, { ...config, effort }, undefined, true); + // 第 4 参(preferredEntry)不传 —— 「配置应用」不是一次模型选择,最近使用不在此记录。 + return selectRow(anchor, { ...config, effort }, undefined, undefined, true); } modelMemory?.setEffort( config.agent, @@ -876,7 +925,7 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified } // 同上:Fast 槽也按 wire id 存取。 if (!modelMemory && onConfigure) { - return selectRow(anchor, { ...config, fast: enabled }, undefined, true); + return selectRow(anchor, { ...config, fast: enabled }, undefined, undefined, true); } modelMemory?.setFast( config.agent, @@ -1105,6 +1154,7 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified anchor: UnifiedAnchor, config: UnifiedRowConfig, favorite?: ModelFavoriteItem, + entry?: UnifiedModelEntry, configuring = false, ): ActionResult => { if (interactionDisabled) return; @@ -1116,31 +1166,38 @@ export function useUnifiedRowActions(options: UnifiedRowActionsOptions): Unified // 交出去的一律是**该引擎的 wire id**(建会话 / 切模型 / 写 draft 都用它); // 行的归一化身份另放在 config.rowModelId 里,调用方要记 override / 收藏时用那个。 const wireModelId = config.wireModelId ?? anchor.modelId; - if (sessionEngineFilter && shouldCrossEngine(config.agent)) { - // 收藏锚点一并交出去:会话侧要在事务**真成功后**才把它记成「当前选中的收藏」 - // (取消 / 失败时什么都没换,锚点当然不能动)。同引擎那一路由 onSelect 的 config 带走。 - return (configuring && sessionEngineFilter.onCrossEngineConfigure - ? sessionEngineFilter.onCrossEngineConfigure - : sessionEngineFilter.onCrossEngineSelect)({ - providerId: anchor.providerId, - modelId: wireModelId, - targetAgent: config.agent, - effort, - // 行上显示的 Fast 就是按目标引擎解析好的那个值(resolveUnifiedRowConfig / - // resolveFavoriteRowConfig 均已过能力门控):显式交给事务,收藏副本「Fast 关」 - // 也要能压过目标记忆里残留的「开」(2026-08-17 review)。 - fast: config.fast, - favoriteUid: favorite ? favorite.uid : null, - }); - } - // 生效引擎 / Fast / 收藏锚点随选中一起交出去:调用方(M5 新会话)要按它派生 - // newMakerDraft 的 vendor,再重推一遍必然与行上显示的三元组漂移。 - return (configuring && onConfigure ? onConfigure : onSelect)(anchor.providerId, wireModelId, effort, { - engine: config.engine, - fast: config.fast, - favoriteUid: favorite ? favorite.uid : null, - rowModelId: anchor.modelId, - }); + const result = + sessionEngineFilter && shouldCrossEngine(config.agent) + ? // 收藏锚点一并交出去:会话侧要在事务**真成功后**才把它记成「当前选中的收藏」 + // (取消 / 失败时什么都没换,锚点当然不能动)。同引擎那一路由 onSelect 的 config 带走。 + // configuring 时优先走配置入口(#4496 的 onCrossEngineConfigure),否则才是切换事务。 + (configuring && sessionEngineFilter.onCrossEngineConfigure + ? sessionEngineFilter.onCrossEngineConfigure + : sessionEngineFilter.onCrossEngineSelect)({ + providerId: anchor.providerId, + modelId: wireModelId, + targetAgent: config.agent, + effort, + // 行上显示的 Fast 就是按目标引擎解析好的那个值(resolveUnifiedRowConfig / + // resolveFavoriteRowConfig 均已过能力门控):显式交给事务,收藏副本「Fast 关」 + // 也要能压过目标记忆里残留的「开」(2026-08-17 review)。 + fast: config.fast, + favoriteUid: favorite ? favorite.uid : null, + }) + : // 生效引擎 / Fast / 收藏锚点随选中一起交出去:调用方(M5 新会话)要按它派生 + // newMakerDraft 的 vendor,再重推一遍必然与行上显示的三元组漂移。 + (configuring && onConfigure ? onConfigure : onSelect)( + anchor.providerId, + wireModelId, + effort, + { + engine: config.engine, + fast: config.fast, + favoriteUid: favorite ? favorite.uid : null, + rowModelId: anchor.modelId, + }, + ); + return rememberRecentModel(result, entry, config); }; return { diff --git a/apps/desktop/src/renderer/contexts/AuthContext.tsx b/apps/desktop/src/renderer/contexts/AuthContext.tsx index c626a5bea8e..e24b6d65909 100644 --- a/apps/desktop/src/renderer/contexts/AuthContext.tsx +++ b/apps/desktop/src/renderer/contexts/AuthContext.tsx @@ -45,6 +45,7 @@ import { isSidebarWindow } from '@/lib/sidebarWindow'; import { isGhostPanelWindow } from '@/lib/ghostPanelWindow'; import { setModelEnginePrefsOwner } from '@/state/modelEnginePrefs'; import { setModelFavoritesOwner } from '@/state/modelFavorites'; +import { setRecentModelsOwner } from '@/state/recentModels'; import { setProviderModelMemoryOwner } from '@/state/providerModelMemory'; import { setAgentDeviceModelMemoryOwner } from '@/state/agentDeviceModelMemory'; import { setFavoriteAnchorMemoryOwner } from '@/state/favoriteAnchorMemory'; @@ -352,6 +353,8 @@ export function AuthProvider({ // 无后缀的默认槽)。漏接 = 多账号串号(providerModelMemory 的旧教训)。 setModelEnginePrefsOwner(state.dataOwnerId); setModelFavoritesOwner(state.dataOwnerId); + // 「最近使用」与收藏同分区:多账号不串流水。 + setRecentModelsOwner(state.dataOwnerId); // 收藏**锚点**记忆(面板上哪一行打勾)与收藏本体同分区:漏接同样是多账号串号。 setFavoriteAnchorMemoryOwner(state.dataOwnerId); setComposerDraftOwner(state.dataOwnerId); diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 665f6d4734d..1a2b37845d2 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -9254,10 +9254,13 @@ }, "unified": { "railFavorites": "Favorites", + "railRecent": "Recent", "railAll": "All", "railRemoteProvider": "{{provider}} · {{device}}", "remoteLoading": "Loading models from that computer…", "favoritesGroup": "Favorites", + "recentGroup": "Recent", + "recentEmpty": "No recent models yet — pick a model and it will show up here", "favoritesEmpty": "No favorites yet — hover a model and hit ☆ to pin it here", "addFavorite": "Save to favorites", "customize": "Customize", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index eb642d64c52..48c9986212a 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -9239,10 +9239,13 @@ }, "unified": { "railFavorites": "お気に入り", + "railRecent": "最近", "railAll": "すべて", "railRemoteProvider": "{{provider}} · {{device}}", "remoteLoading": "そのパソコンのモデルを読み込み中…", "favoritesGroup": "お気に入り", + "recentGroup": "最近", + "recentEmpty": "最近使ったモデルはまだありません——モデルを選ぶとここに表示されます", "favoritesEmpty": "お気に入りはまだありません——モデルの行にカーソルを合わせて ☆ で保存できます", "addFavorite": "お気に入りに保存", "customize": "カスタマイズ", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index a68af2b664f..b3c5cb0a11a 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -9239,10 +9239,13 @@ }, "unified": { "railFavorites": "즐겨찾기", + "railRecent": "최근", "railAll": "전체", "railRemoteProvider": "{{provider}} · {{device}}", "remoteLoading": "그 컴퓨터의 모델을 불러오는 중…", "favoritesGroup": "즐겨찾기", + "recentGroup": "최근", + "recentEmpty": "최근 사용한 모델이 없습니다 — 모델을 선택하면 여기에 표시됩니다", "favoritesEmpty": "즐겨찾기가 없습니다 — 모델 행에 마우스를 올리고 ☆를 눌러 저장하세요", "addFavorite": "즐겨찾기에 저장", "customize": "사용자 지정", 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 f409c00a369..22464a22f6f 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -9239,10 +9239,13 @@ }, "unified": { "railFavorites": "收藏", + "railRecent": "最近", "railAll": "全部", "railRemoteProvider": "{{provider}} · {{device}}", "remoteLoading": "正在读取那台电脑的模型…", "favoritesGroup": "收藏", + "recentGroup": "最近", + "recentEmpty": "还没有最近使用——选一个模型后,它会出现在这里", "favoritesEmpty": "还没有收藏——把鼠标停到模型行上,点 ☆ 存到这里", "addFavorite": "存为收藏", "customize": "自定义", 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 d11d60adc10..a2773e4170c 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -9238,10 +9238,13 @@ }, "unified": { "railFavorites": "收藏", + "railRecent": "最近", "railAll": "全部", "railRemoteProvider": "{{provider}} · {{device}}", "remoteLoading": "正在讀取那臺電腦的模型…", "favoritesGroup": "收藏", + "recentGroup": "最近", + "recentEmpty": "還沒有最近使用——選一個模型後,它會出現在這裡", "favoritesEmpty": "還沒有收藏——把滑鼠停到模型列上,點 ☆ 存到這裡", "addFavorite": "存為收藏", "customize": "自訂", diff --git a/apps/desktop/src/renderer/state/modelConfigCopy.ts b/apps/desktop/src/renderer/state/modelConfigCopy.ts new file mode 100644 index 00000000000..b2966da4725 --- /dev/null +++ b/apps/desktop/src/renderer/state/modelConfigCopy.ts @@ -0,0 +1,96 @@ +/** + * modelConfigCopy —— 「配置副本」的公共归一化与身份规则:收藏(modelFavorites)与 + * 最近使用(recentModels)共用的那一份定义。 + * + * 副本 = 模型身份 + 引擎 + 深度 + Fast 的完整组合。两个 store 都按同一套语义存取: + * - providerId 拒绝 providerModelMemory 的保留位 `'*'`(MODEL_PRESET_SLOT_ID); + * - agent 非法 → **整条丢弃**(副本缺引擎无法表达任何配置);合法值域是选择器 / 草稿 + * 链路的 vendor 口径(`SelectableVendor`); + * - effort 存 canonical key(`'high'` / `'low'`…),显示文案不回灌;非法值只丢该字段, + * 缺省语义 = 跟随该 (模型, 引擎) 的推荐档; + * - fast 只在**开启**时存 `true`(关闭即缺省,不落「等于默认」的快照); + * - 身份(去重 / 星标匹配) = providerId + modelId + agent + effort + fast,缺省字段参与, + * 与「跟随推荐」区分。 + * + * 为什么单点:收藏的「重复添加去重」与最近的「同一模型不同配置各占一行」「星标按完全一致的 + * 副本匹配」必须用同一套判等 —— 各写一遍必然漂移成两套身份(收藏存的 effort 是 key、 + * 最近存的却是显示文案之类)。 + */ + +import { EFFORT_VALUES } from '@cindy/model-providers'; + +import { isSelectableVendor } from '@/lib/agentVendors'; +import type { Effort } from '@/lib/userPreferences.types'; + +import type { ModelEngine } from './modelEnginePrefs'; + +/** + * v2 记忆 schema 内的保留来源 id:同一 agent 下跨真实 provider 共享的模型级预设槽。 + * + * 定义放在这里(配置副本的身份空间):两个副本 store 的归一化都要拒绝它,而它们是 + * **纯模块**——保留位反向 import providerModelMemory 会把整个 store(含 React 与 + * 存储订阅)拖进纯逻辑层的依赖图。providerModelMemory 原样 re-export 给既有消费方, + * 常量仍然只有一份定义。 + */ +export const MODEL_PRESET_SLOT_ID = '*'; + +/** 一份配置副本的完整描述(不含各 store 自己的锚点 / 时间戳)。 */ +export interface ModelConfigCopy { + providerId: string; + modelId: string; + agent: ModelEngine; + /** 思考深度**档位 key**('low' | 'high' | …);缺省 = 跟随该 (模型, 引擎) 的推荐档。 */ + effort?: Effort; + /** Fast(插队加速)。**只在开启时存 true**,关闭即缺省 —— 不落「等于默认」的快照。 */ + fast?: true; +} + +/** + * `'*'` 是 providerModelMemory v2 的保留来源 id(跨来源模型预设槽),真实来源不可能等于它 + * (规格 §4「偏好/记忆」的防撞要求)。 + */ +export function isUsableModelProviderId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value !== MODEL_PRESET_SLOT_ID; +} + +export function isCanonicalModelEffort(value: unknown): value is Effort { + return typeof value === 'string' && (EFFORT_VALUES as readonly string[]).includes(value); +} + +/** + * 归一化配置字段(收藏 / 最近的 add / update / sanitize 共用);模型身份或引擎不合法 → null。 + * `effort` 非法只丢该字段(不是丢整条):缺省有明确语义「跟随推荐档」。 + */ +export function normalizeModelConfigCopy(raw: { + providerId?: unknown; + modelId?: unknown; + agent?: unknown; + effort?: unknown; + fast?: unknown; +}): ModelConfigCopy | null { + const providerId = typeof raw.providerId === 'string' ? raw.providerId.trim() : ''; + const modelId = typeof raw.modelId === 'string' ? raw.modelId.trim() : ''; + if (!isUsableModelProviderId(providerId) || !modelId) return null; + // agent 非法 → **丢整条**:副本的必要组成部分,缺了它这条记录无法表达任何配置。 + if (!isSelectableVendor(raw.agent)) return null; + const config: ModelConfigCopy = { providerId, modelId, agent: raw.agent }; + if (isCanonicalModelEffort(raw.effort)) config.effort = raw.effort; + if (raw.fast === true) config.fast = true; + return config; +} + +/** + * 去重 / 匹配身份:providerId + modelId + agent + effort + fast(缺省字段参与,与「跟随推荐」区分)。 + * 分隔符用空格而不是 NUL:源码里嵌一个裸 `\0` 会让整个文件被 git / rg / grep 判成二进制 + * (diff 显示 `Bin`、搜不到任何符号),代价远大于它能防的那点分隔符冲突 —— provider id 与 + * model id 都是 slug 形态,不含空格(与 unifiedSelection.entryKey 同一取舍)。 + */ +export function modelConfigCopyIdentity(config: ModelConfigCopy): string { + return [ + config.providerId, + config.modelId, + config.agent, + config.effort ?? '', + config.fast === true ? '1' : '0', + ].join(' '); +} diff --git a/apps/desktop/src/renderer/state/modelFavorites.ts b/apps/desktop/src/renderer/state/modelFavorites.ts index fbca19fdccf..940b977dfd2 100644 --- a/apps/desktop/src/renderer/state/modelFavorites.ts +++ b/apps/desktop/src/renderer/state/modelFavorites.ts @@ -67,28 +67,23 @@ import { parseModelFavoriteMutation, sameModelFavorite, type ModelFavoriteMutati import { useSyncExternalStore } from 'react'; -import { EFFORT_VALUES } from '@cindy/model-providers'; - import { isSelectableVendor } from '@/lib/agentVendors'; import type { Effort } from '@/lib/userPreferences.types'; +import { + isCanonicalModelEffort, + modelConfigCopyIdentity, + normalizeModelConfigCopy, + type ModelConfigCopy, +} from './modelConfigCopy'; import type { ModelEngine } from './modelEnginePrefs'; -import { MODEL_PRESET_SLOT_ID } from './providerModelMemory'; import { createStorageReconciler } from './storageOpReplay'; let requireDurableWrite = false; const STORAGE_KEY = 'xdt:modelFavorites:v1'; -/** 一条收藏所描述的完整配置(不含锚点)。 */ -export interface ModelFavoriteConfig { - providerId: string; - modelId: string; - agent: ModelEngine; - /** 思考深度**档位 key**('low' | 'high' | …);缺省 = 跟随该 (模型, 引擎) 的推荐档。 */ - effort?: Effort; - /** Fast(插队加速)。**只在开启时存 true**,关闭即缺省 —— 不落「等于默认」的快照。 */ - fast?: true; -} +/** 一条收藏所描述的完整配置(不含锚点)。归一化 / 身份规则与最近使用共用(见 modelConfigCopy)。 */ +export type ModelFavoriteConfig = ModelConfigCopy; /** 落盘 / 消费的收藏条目:配置 + 独立锚点 uid。 */ export interface ModelFavoriteItem extends ModelFavoriteConfig { @@ -138,59 +133,10 @@ function seqOfUid(uid: string): number | null { return Number.isSafeInteger(n) ? n : null; } -function isCanonicalEffort(value: unknown): value is Effort { - return typeof value === 'string' && (EFFORT_VALUES as readonly string[]).includes(value); -} - -/** - * `'*'` 是 providerModelMemory v2 的保留来源 id(跨来源模型预设槽)。收藏条目的 providerId - * 必须是真实来源,撞上保留位直接丢条目(规格 §4「偏好/记忆」的防撞要求)。 - */ -function isUsableProviderId(value: unknown): value is string { - return typeof value === 'string' && value.length > 0 && value !== MODEL_PRESET_SLOT_ID; -} - -/** 归一化配置字段(供 add / update / sanitize 共用);模型身份或引擎不合法 → null。 */ -function normalizeConfig(raw: { - providerId?: unknown; - modelId?: unknown; - agent?: unknown; - effort?: unknown; - fast?: unknown; -}): ModelFavoriteConfig | null { - const providerId = typeof raw.providerId === 'string' ? raw.providerId.trim() : ''; - const modelId = typeof raw.modelId === 'string' ? raw.modelId.trim() : ''; - if (!isUsableProviderId(providerId) || !modelId) return null; - // agent 非法 → **丢整条**:收藏是「配置副本」,引擎是副本的必要组成部分,缺了它这条 - // 记录无法表达任何配置(与 effort 不同 —— effort 缺省有明确语义「跟随推荐档」)。 - if (!isSelectableVendor(raw.agent)) return null; - const config: ModelFavoriteConfig = { providerId, modelId, agent: raw.agent }; - // effort 非法(显示文案 / 过期档名 / 非字符串)→ 只丢这个字段,条目保留,调用层回落推荐档。 - if (isCanonicalEffort(raw.effort)) config.effort = raw.effort; - if (raw.fast === true) config.fast = true; - return config; -} - -/** - * 去重身份:providerId + modelId + agent + effort + fast(缺省字段参与,与「跟随推荐」区分)。 - * 分隔符用空格而不是 NUL:源码里嵌一个裸 `\0` 会让整个文件被 git / rg / grep 判成二进制 - * (diff 显示 `Bin`、搜不到任何符号),代价远大于它能防的那点分隔符冲突 —— provider id 与 - * model id 都是 slug 形态,不含空格(与 unifiedSelection.entryKey 同一取舍)。 - */ -function identityOf(config: ModelFavoriteConfig): string { - return [ - config.providerId, - config.modelId, - config.agent, - config.effort ?? '', - config.fast === true ? '1' : '0', - ].join(' '); -} - /** * 严格校验 + 锚点补齐。老版本 / 手改 localStorage 损坏时静默回退空表,不抛。 * - 形状非法的条目(非对象 / 缺模型身份 / 引擎不认识 / providerId 撞 `'*'`)整条丢弃; - * - effort 非法只丢字段(见 normalizeConfig); + * - effort 非法只丢字段(规则与「最近使用」共用,见 modelConfigCopy.normalizeModelConfigCopy); * - uid 缺失 / 非字符串 / 与前面的条目重复 → 就地补一个新 uid(收藏靠 uid 做锚点, * 重复 uid 会让 hover / 删除 / 选中打到错误的条目); * - uidSeq 非正整数,或小于已见 uid 的序号 + 1 → 抬到安全值,保证后续新 uid 不撞已有锚点。 @@ -206,7 +152,7 @@ function sanitize(raw: unknown): FavoritesState { const parsed: Array<{ config: ModelFavoriteConfig; uid: string | null }> = []; for (const entry of rawItems) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; - const config = normalizeConfig(entry as Record); + const config = normalizeModelConfigCopy(entry as Record); if (!config) continue; const rawUid = (entry as { uid?: unknown }).uid; const uid = typeof rawUid === 'string' && rawUid.length > 0 && !seenUids.has(rawUid) @@ -309,7 +255,7 @@ function sameState(a: FavoritesState, b: FavoritesState): boolean { && a.items.every((item, i) => { const other = b.items[i]; return ( - other !== undefined && item.uid === other.uid && identityOf(item) === identityOf(other) + other !== undefined && item.uid === other.uid && modelConfigCopyIdentity(item) === modelConfigCopyIdentity(other) ); }) ); @@ -333,10 +279,10 @@ function appendFavorite( config: ModelFavoriteConfig, preferredUid: string, ): FavoritesState { - const identity = identityOf(config); + const identity = modelConfigCopyIdentity(config); // 幂等的关键一半:同配置已在表里(可能是本次同步写留下的,也可能是另一窗口存过的) // → 原样返回,重放不会堆出第二条。 - if (state.items.some((item) => identityOf(item) === identity)) return state; + if (state.items.some((item) => modelConfigCopyIdentity(item) === identity)) return state; const taken = new Set(state.items.map((item) => item.uid)); let uid = preferredUid; if (!uid || taken.has(uid)) { @@ -388,7 +334,7 @@ function patchConfig( } if ('effort' in patch) { // null = 显式清除(回落推荐档);非法值同样按清除处理(不写脏档名)。 - if (isCanonicalEffort(patch.effort)) next.effort = patch.effort; + if (isCanonicalModelEffort(patch.effort)) next.effort = patch.effort; else delete next.effort; } if (patch.fast !== undefined) { @@ -423,7 +369,7 @@ function applyOp(state: FavoritesState, op: FavoritesOp): FavoritesState { const patched = patchConfig(configOf(current), op.patch); if (!patched) return state; const next: ModelFavoriteItem = { uid: current.uid, ...patched }; - if (identityOf(next) === identityOf(current)) return state; + if (modelConfigCopyIdentity(next) === modelConfigCopyIdentity(current)) return state; const items = [...state.items]; items[index] = next; return { ...state, items }; @@ -619,13 +565,13 @@ export function getModelFavorite(uid: string): ModelFavoriteItem | undefined { * effort 非法只丢该字段(条目仍建,回落推荐档)。 */ export function addModelFavorite(config: ModelFavoriteConfig): string { - const normalized = normalizeConfig(config); + const normalized = normalizeModelConfigCopy(config); if (!normalized) return ''; // 基底取**重读后的**持久化快照(见 freshState):另一窗口刚加的条目要一起带上, - // 否则本次整表写回会把它抹掉。uidSeq 的单调性、identityOf 去重都在这份新鲜基底上判。 + // 否则本次整表写回会把它抹掉。uidSeq 的单调性、modelConfigCopyIdentity 去重都在这份新鲜基底上判。 const state = freshState(); - const identity = identityOf(normalized); - const existing = state.items.find((item) => identityOf(item) === identity); + const identity = modelConfigCopyIdentity(normalized); + const existing = state.items.find((item) => modelConfigCopyIdentity(item) === identity); if (existing) return existing.uid; const uid = uidOfSeq(state.uidSeq); // uid 必须**当场**返回(调用方要拿它当选中锚点),所以先在这份基底上定下首选锚点; @@ -643,7 +589,7 @@ export function addModelFavorite(config: ModelFavoriteConfig): string { * - 只投放一次:`seeded` 标记持久化,取消后不复种(取消即显式否决推荐); * - 只对**从未收藏过**的用户投放:已有收藏说明用户在整理自己的列表,不打扰, * 但同样落下标记(这一版的推荐对 TA 已经「见过即弃权」); - * - 配置字段与普通收藏同一套校验(normalizeConfig),effort / fast 缺省跟随推荐档。 + * - 配置字段与普通收藏同一套校验(normalizeModelConfigCopy),effort / fast 缺省跟随推荐档。 */ export function seedDefaultFavorite(config: ModelFavoriteConfig): void { // 同 addModelFavorite:基底必须新鲜 —— 另一窗口若已投放过种子,这里读到的 seeded @@ -651,7 +597,7 @@ export function seedDefaultFavorite(config: ModelFavoriteConfig): void { const state = freshState(); if (state.seeded) return; // 非法配置不落标记:下次给出合法推荐时仍要能投放。 - const normalized = normalizeConfig(config); + const normalized = normalizeModelConfigCopy(config); if (!normalized) return; commitOp({ kind: 'seed', config: normalized, preferredUid: uidOfSeq(state.uidSeq) }, state); } @@ -740,9 +686,9 @@ export function accessHostModelFavorites(ownerId: string | null, mutation?: Mode requireDurableWrite = true; try { if (op.kind === 'add') { - const config = normalizeConfig(op.item); + const config = normalizeModelConfigCopy(op.item); if (!config) throw new Error('Invalid favorite'); - if (base.items.length >= 4096 && !base.items.some(item => identityOf(item) === identityOf(config))) throw new Error('Favorites limit reached'); + if (base.items.length >= 4096 && !base.items.some(item => modelConfigCopyIdentity(item) === modelConfigCopyIdentity(config))) throw new Error('Favorites limit reached'); commitOp({kind:'add',config,preferredUid:uidOfSeq(base.uidSeq)},base); } else if (op.kind === 'remove') commitOp({kind:'remove',uid:op.expected.uid},base); else commitOp({kind:'update',uid:op.expected.uid,patch:{agent:op.item.agent, effort:(op.item.effort as Effort | undefined) ?? null, fast:!!op.item.fast}},base); diff --git a/apps/desktop/src/renderer/state/providerModelMemory.ts b/apps/desktop/src/renderer/state/providerModelMemory.ts index cb4017d5472..95b58dbaf99 100644 --- a/apps/desktop/src/renderer/state/providerModelMemory.ts +++ b/apps/desktop/src/renderer/state/providerModelMemory.ts @@ -46,7 +46,9 @@ const STORAGE_KEY = 'xdt:providerModelMemory:v2'; /** 历史单槽版本 key —— 冷启动迁移源(只读,不再写)。 */ const LEGACY_STORAGE_KEY_V1 = 'xdt:providerModelMemory:v1'; /** v2 schema 内的保留来源 id:同一 agent 下跨真实 provider 共享的模型级预设槽。 */ -export const MODEL_PRESET_SLOT_ID = '*'; +// 定义在 modelConfigCopy(配置副本的归一化要拒绝它),这里 re-export 给既有消费方。 +import { MODEL_PRESET_SLOT_ID } from './modelConfigCopy'; +export { MODEL_PRESET_SLOT_ID }; let activeDataOwnerId: string | null = null; diff --git a/apps/desktop/src/renderer/state/recentModels.ts b/apps/desktop/src/renderer/state/recentModels.ts new file mode 100644 index 00000000000..429fbc72637 --- /dev/null +++ b/apps/desktop/src/renderer/state/recentModels.ts @@ -0,0 +1,420 @@ +/** + * recentModels —— 统一模型选择器的「最近」视图存储(model-selector-unified §1.2): + * 用户在**对话**里用过的**配置副本**(模型 + 引擎 + 深度 + Fast),按最近一次使用时间倒序。 + * + * 语义边界: + * - 收藏是**用户钉住**的配置副本(手动添加 / 删除,长期保留); + * - 最近是**自动记录**的配置副本(选择成功即记录,超出容量自动淘汰)。 + * 两者是同一套「配置副本」语义(归一化 / 身份规则共用 modelConfigCopy),所以: + * - 同一模型用不同深度 / 引擎 / Fast 各占**一行**(和收藏一致,不是按模型去重); + * - 星标(面板绘制)按**完全一致的副本**匹配收藏 —— 该条副本已在收藏里就点亮, + * 没有则点星把这份副本存进收藏;匹配与去重都走同一份身份(modelConfigCopyIdentity)。 + * 与收藏的区别只在生命周期与入口:最近不能手动编辑(它是流水账,不是用户整理的清单), + * 由记录点自动写入、超过容量自动淘汰。 + * + * 记录点只有一个:对话入口里「选择真的应用成功」的那一下(useUnifiedRowActions.selectRow; + * 草稿 / 会话 / 跨引擎三条链路都在那里收口),记的是**那一刻生效的整份配置**。 + * 定时任务、IM 默认、Bot、Hook、Worker、子代理与设置页共用的同一面板**不记**(那些是配置 + * 动作,不是「用这个模型跑过活」);远程(SSH / device-link)会话与草稿也不记:模型来自被控端 + * 目录,写进本机列表只会得到一行永远不可路由的记录(与「远程模型不写入本机记忆」同向)。 + * + * 持久化:localStorage,owner 分区(与 modelFavorites / modelEnginePrefs 同形)。写频率极低 + * (用户选一次模型写一次),**同步写** —— 热更 relaunch 走 app.exit() 强退,异步写会丢最近 + * 一次改动。写失败静默吞,内存态照常生效。 + * + * 并发写(多窗口):沿用 storageOpReplay 的「同步乐观写 + 会话 op-log + 事件驱动的持续调和」 + * (机制正文在那个文件头,那里是唯一权威)。这里只记本 store 的落地方式: + * - 每个写入表达成一个可重放的 op(`record`:一次「这份配置在此时被用过」的断言); + * - `applyOp(state, op)` 按副本身份取 max(usedAt) 并入,无变化时返回原对象; + * - 整条 log 的重放要保持幂等:`compactRecentOps` 把同一副本身份的历史 op 折成一条, + * 否则重放会拿旧 usedAt 去顶掉后来的记录; + * - 没有删除类 op(用户不能「删掉最近用过」),所以不设 tombstone。 + * 与收藏相比残余边界更温和:即使调和完全失效,最坏也只是少一条快捷方式,不丢用户手存的数据。 + */ + +import { useSyncExternalStore } from 'react'; + +import { + modelConfigCopyIdentity, + normalizeModelConfigCopy, + type ModelConfigCopy, +} from './modelConfigCopy'; +import { createStorageReconciler } from './storageOpReplay'; + +const STORAGE_KEY = 'xdt:recentModels:v1'; + +/** + * 本机保留的流水条数。刻意大于展示条数:最近几条恰好指向不可路由的模型(来源断开 / 目录 + * 下架)时,面板能往后补足 5 条,而不是显示成「最近只用过 2 个」。 + */ +export const RECENT_MODELS_KEEP = 20; + +/** 落盘 / 消费的一条记录:配置副本 + 最近一次使用时间。 */ +export interface RecentModelItem extends ModelConfigCopy { + /** 最近一次使用时间(epoch ms);排序与跨窗口合并都以它为准。 */ + usedAt: number; +} + +interface RecentModelsState { + /** 按 usedAt 倒序。 */ + items: RecentModelItem[]; +} + +let activeDataOwnerId: string | null = null; + +function storageKey(): string { + return activeDataOwnerId ? `${STORAGE_KEY}:${encodeURIComponent(activeDataOwnerId)}` : STORAGE_KEY; +} + +function emptyState(): RecentModelsState { + return { items: [] }; +} + +/** + * 严格校验一条记录:配置字段走与收藏同一套归一化(见 modelConfigCopy),时间不合法 → null。 + * 模型身份或引擎不合法整条丢弃;effort 非法只丢该字段。 + */ +function normalizeItem(raw: { + providerId?: unknown; + modelId?: unknown; + agent?: unknown; + effort?: unknown; + fast?: unknown; + usedAt?: unknown; +}): RecentModelItem | null { + const config = normalizeModelConfigCopy(raw); + if (!config) return null; + if (typeof raw.usedAt !== 'number' || !Number.isFinite(raw.usedAt) || raw.usedAt <= 0) { + return null; + } + return { ...config, usedAt: raw.usedAt }; +} + +/** + * 严格校验 + 归一化。老版本 / 手改 localStorage 损坏时静默回退空表,不抛。 + * - 形状非法的条目整条丢弃; + * - 同一**副本身份**出现多条时只保 usedAt 最新的那条; + * - 按 usedAt 倒序,并裁到 RECENT_MODELS_KEEP 条。 + */ +function sanitize(raw: unknown): RecentModelsState { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return emptyState(); + const rawItems = Array.isArray((raw as { items?: unknown }).items) + ? ((raw as { items: unknown[] }).items) + : []; + const byKey = new Map(); + for (const entry of rawItems) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const item = normalizeItem(entry as Record); + if (!item) continue; + const key = modelConfigCopyIdentity(item); + const existing = byKey.get(key); + if (!existing || item.usedAt > existing.usedAt) byKey.set(key, item); + } + return { + items: [...byKey.values()].sort((a, b) => b.usedAt - a.usedAt).slice(0, RECENT_MODELS_KEEP), + }; +} + +// 进程内缓存(惰性加载)。读多写少,避免每次读都 parse localStorage。 +let cache: RecentModelsState | null = null; + +/** + * 按**给定 key** 读原始 localStorage(不碰缓存)。key 可能不是当前 active 分区 —— + * 登出 / 切号之后旧分区的调和仍要按它自己的 key 读写(机制见 storageOpReplay 文件头)。 + */ +function loadFromKey(key: string): RecentModelsState { + if (typeof window === 'undefined') return emptyState(); + try { + const raw = window.localStorage.getItem(key); + return raw ? sanitize(JSON.parse(raw)) : emptyState(); + } catch { + return emptyState(); + } +} + +function loadFromStorage(): RecentModelsState { + return loadFromKey(storageKey()); +} + +function load(): RecentModelsState { + if (!cache) cache = loadFromStorage(); + return cache; +} + +/** + * **写路径的基底** —— 每次写入前重读 localStorage,拿到的是此刻的共享真相,而不是本窗口的 + * 内存快照(理由与 modelFavorites.freshState 逐字相同:storage 事件是异步的,整表写回用陈旧 + * 基底会抹掉另一窗口刚记的那条)。读不出来时退回内存缓存,不退回空表 —— 私密窗口 / 写满时 + * setItem 静默失败、getItem 恒 null,拿空表当基底会把本次会话已记的全部抹掉。 + */ +function freshState(): RecentModelsState { + if (typeof window === 'undefined') return load(); + let raw: string | null = null; + try { + raw = window.localStorage.getItem(storageKey()); + } catch { + return load(); + } + if (raw === null) return load(); + try { + return sanitize(JSON.parse(raw)); + } catch { + return load(); + } +} + +/** 两份状态在用户可见语义上是否一致(副本身份 + 时间 + 顺序)。 */ +function sameState(a: RecentModelsState, b: RecentModelsState): boolean { + return ( + a.items.length === b.items.length && + a.items.every((item, i) => { + const other = b.items[i]; + return ( + other !== undefined && + modelConfigCopyIdentity(item) === modelConfigCopyIdentity(other) && + item.usedAt === other.usedAt + ); + }) + ); +} + +// ── 可重放的写操作(见文件头「并发写」)──────────────────────────────────── + +/** 一次「这份配置在此时被用过」的断言。只依赖自身 + 目标状态,重放才成立。 */ +type RecentModelsOp = RecentModelItem; + +/** + * 把一条记录并入状态:同副本身份取 max(usedAt),移到队首,按时间倒序并裁剪。 + * **无实际变化时返回入参对象本身**(引用判等决定要不要落盘 / 这一轮是否真的断言了什么)。 + */ +function applyOp(state: RecentModelsState, op: RecentModelsOp): RecentModelsState { + const item = normalizeItem(op); + if (!item) return state; + const key = modelConfigCopyIdentity(item); + const existing = state.items.find((entry) => modelConfigCopyIdentity(entry) === key); + const usedAt = existing ? Math.max(existing.usedAt, item.usedAt) : item.usedAt; + const rest = state.items.filter((entry) => modelConfigCopyIdentity(entry) !== key); + const next: RecentModelsState = { + items: [{ ...item, usedAt }, ...rest] + .sort((a, b) => b.usedAt - a.usedAt) + .slice(0, RECENT_MODELS_KEEP), + }; + return sameState(state, next) ? state : next; +} + +/** + * op-log 的归并:同一副本身份的历史 op 折成一条 —— 否则整条 log 重放时,先录的旧 usedAt + * 会被再次并入(虽然取 max 不会把时间改小,但会让「这次记录」在 log 里出现两遍, + * 无谓地占用 TTL / 断言次数)。 + * 时间更旧的 op(时钟回拨 / 跨窗口乱序)直接放弃:重放不能把已有记录的时间往回拧。 + */ +function compactRecentOps( + log: readonly RecentModelsOp[], + op: RecentModelsOp, +): readonly RecentModelsOp[] { + const key = modelConfigCopyIdentity(op); + const existing = log.find((entry) => modelConfigCopyIdentity(entry) === key); + if (existing && existing.usedAt > op.usedAt) return log; + return [...log.filter((entry) => modelConfigCopyIdentity(entry) !== key), op]; +} + +const reconciler = createStorageReconciler({ + // active 分区走 freshState(带「读不出来就退回内存缓存」的兜底);其它分区(登出 / 切号后的 + // 旧 key)按 key 直读。 + read: (key) => (key === storageKey() ? freshState() : loadFromKey(key)), + apply: applyOp, + persist: (key, state) => persistTo(key, state), + adopt: (key, state) => { + if (key !== storageKey()) return; + if (sameState(cache ?? emptyState(), state)) return; + cache = state; + emit(); + }, + compact: compactRecentOps, +}); + +/** + * 两份状态取并集:同一副本身份取 max(usedAt),按时间倒序并裁到 KEEP 条。 + * 只增不减(本 store 没有删除类 op)—— 把「本窗已记但没落盘」的条目并回写基底。 + * 没有新身份 / 时间没前进时**返回入参对象本身**,调用方据此判断要不要落盘。 + */ +function mergeState(base: RecentModelsState, extra: RecentModelsState | null): RecentModelsState { + if (!extra || extra.items.length === 0) return base; + const byKey = new Map(base.items.map((item) => [modelConfigCopyIdentity(item), item])); + let changed = false; + for (const item of extra.items) { + const key = modelConfigCopyIdentity(item); + const existing = byKey.get(key); + if (!existing) { + byKey.set(key, item); + changed = true; + continue; + } + if (item.usedAt > existing.usedAt) { + byKey.set(key, item); + changed = true; + } + } + if (!changed) return base; + return { + items: [...byKey.values()].sort((a, b) => b.usedAt - a.usedAt).slice(0, RECENT_MODELS_KEEP), + }; +} + +/** + * 一次写入 = **同步乐观写**(热更强退不丢)+ 把 op 记进**当时那个 key** 的会话 op-log 并调度 + * 一次锁内调和。owner 随后被切走也不放弃调和 —— 调和按捕获的 key 自洽运行。 + * + * 写基底 = **磁盘真相 ∪ 本窗内存态**(2026-09-16 review P2):上一次写若失败(配额 / 私密 + * 窗口),那条记录只活在本窗内存里,而 `freshState()` 只按 key 读回磁盘 —— 拿它当基底再写一次 + * 会把「刚记下但没落盘」的那条顺手抹掉(磁盘上有旧快照时尤其明显)。并集只增不减,重复写无害。 + */ +function commitOp(op: RecentModelsOp): void { + const base = mergeState(freshState(), cache); + const next = applyOp(base, op); + if (next !== base) persist(next); + const key = storageKey(); + reconciler.record(key, op); + reconciler.schedule(key); +} + +// ── 订阅(供 useSyncExternalStore)────────────────────────────────────────── +const listeners = new Set<() => void>(); + +function emit(): void { + for (const listener of listeners) listener(); +} + +/** + * 落盘到**指定 key**。缓存与通知只在该 key 恰是当前 active 分区时才做(调和可能发生在 + * 登出 / 切号之后的旧分区上)。 + */ +function persistTo(key: string, next: RecentModelsState): void { + if (typeof window !== 'undefined') { + try { + // 同步写:见文件头(热更 relaunch 走 app.exit() 强退)。 + window.localStorage.setItem(key, JSON.stringify(next)); + } catch { + // localStorage 满 / 私密窗口禁写 —— 静默吞,内存态仍生效。 + } + } + if (key !== storageKey()) return; + cache = next; + emit(); +} + +function persist(next: RecentModelsState): void { + persistTo(storageKey(), next); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getItemsSnapshot(): readonly RecentModelItem[] { + return load().items; +} + +const removeStorageListener = (() => { + if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return null; + /** 本 owner 分区的内存态跟外来写入对齐(重读真相,不采信 event.newValue)。 */ + const refreshActive = (): void => { + const next = loadFromStorage(); + if (sameState(cache ?? emptyState(), next)) return; + cache = next; + emit(); + }; + const onStorage = (event: StorageEvent): void => { + if (event.storageArea && event.storageArea !== window.localStorage) return; + // key === null 表示 storage.clear():本分区刷新,并让所有还有 op-log 的分区各自调和。 + if (event.key === null) { + refreshActive(); + for (const key of reconciler.loggedKeys()) reconciler.schedule(key); + return; + } + if (event.key === storageKey()) { + refreshActive(); + // 外来写入可能正是「别窗用旧基底做的迟到覆盖」,抹掉了本窗刚记的 op: + // 在锁内把本分区的整条 op-log 重新断言一遍,无差异即终止。 + reconciler.schedule(event.key); + return; + } + // 非 active 分区:只要 op-log 里还有它的记录就照样调和。 + if (reconciler.hasOps(event.key)) reconciler.schedule(event.key); + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); +})(); + +if (import.meta.hot) { + import.meta.hot.dispose(() => { + removeStorageListener?.(); + }); +} + +/** 读全部记录(usedAt 倒序,含当前不可路由的条目 —— 过滤是面板的事)。数组视为只读。 */ +export function listRecentModels(): readonly RecentModelItem[] { + return getItemsSnapshot(); +} + +/** + * 记一次「用了这份配置」。非法入参静默 no-op(与收藏同一条防线)。 + * `now` 只给测试注入用:生产路径一律取当前时刻。 + */ +export function recordRecentModel(config: ModelConfigCopy, now: number = Date.now()): void { + const item = normalizeItem({ ...config, usedAt: now }); + if (!item) return; + commitOp(item); +} + +/** 订阅变更(非 React 调用方)。 */ +export function subscribeRecentModels(listener: () => void): () => void { + return subscribe(listener); +} + +/** + * React hook —— 记录列表快照。数组身份只在真正写入 / 跨窗口同步时变化, + * 可直接进 useMemo 依赖(useSyncExternalStore 保证 StrictMode 双 render 安全)。 + */ +export function useRecentModels(): readonly RecentModelItem[] { + return useSyncExternalStore(subscribe, getItemsSnapshot, getItemsSnapshot); +} + +/** 随当前数据归属账号切换持久化命名空间(与 setModelFavoritesOwner 同形)。 */ +export function setRecentModelsOwner(ownerId: string | null): void { + const normalized = typeof ownerId === 'string' && ownerId.trim().length > 0 ? ownerId : null; + if (activeDataOwnerId === normalized) return; + activeDataOwnerId = normalized; + cache = null; + emit(); +} + +/** + * 当前分区归属(可能为 null = 未登录 / 本地模式)。调用方在**发起异步选择时**捕获它,回调落地 + * 前再比一次 —— 归属变了就不能把记录写进新分区(`xdt:recentModels:v1:`)。 + */ +export function getRecentModelsOwner(): string | null { + return activeDataOwnerId; +} + +/** 测试用 —— 重置缓存 / owner / 订阅者 / op-log + 清 localStorage(其它代码不应调用)。 */ +export function __resetForTest(): void { + const keyBeforeReset = storageKey(); + cache = null; + listeners.clear(); + reconciler.__resetForTest(); + if (typeof window !== 'undefined') { + try { + window.localStorage.removeItem(keyBeforeReset); + window.localStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } + } + activeDataOwnerId = null; +} + +export const __STORAGE_KEY = STORAGE_KEY; diff --git a/apps/desktop/src/renderer/state/storageOpReplay.ts b/apps/desktop/src/renderer/state/storageOpReplay.ts index 6548d901d67..89e6deebf21 100644 --- a/apps/desktop/src/renderer/state/storageOpReplay.ts +++ b/apps/desktop/src/renderer/state/storageOpReplay.ts @@ -1,10 +1,10 @@ /** - * storageOpReplay —— `modelFavorites` 与 `modelEnginePrefs` 两个 localStorage store 的 - * **跨 renderer 并发一致性**小工具。刻意只服务这两个文件,不做成通用存储框架:它依赖 - * 「写操作能表达成可重放且幂等的 op」这一前提,而那是那两个 store 自己的设计约束。 + * storageOpReplay —— `modelFavorites` / `modelEnginePrefs` / `recentModels` 三个 localStorage + * store 的**跨 renderer 并发一致性**小工具。刻意只服务这三个文件,不做成通用存储框架:它依赖 + * 「写操作能表达成可重放且幂等的 op」这一前提,而那是那三个 store 自己的设计约束。 * * 要解决的问题(2026-08-17 review H1 / K1 / K2): - * 两个 store 都是**整表写回**。「写前重读 localStorage」只能修「另一窗口先写完、事件还没 + * 三个 store 都是**整表写回**。「写前重读 localStorage」只能修「另一窗口先写完、事件还没 * 到」那一路;两个 renderer 若**都在对方写回之前**读了同一份旧快照,后写者仍然整表覆盖 * 先写者 —— 新增丢失、编辑丢失,删除与编辑交错时已删的条目还会复活。localStorage 没有 * CAS,同进程 JS 单线程,但**跨 renderer 进程**的 getItem / setItem 可以任意交错,所以 @@ -27,7 +27,7 @@ * owner 是谁**(K1 修复点):调和按捕获的 key 自洽运行,登出 / 切号后旧分区的 log 继续有效; * 仅当 key 恰好是当前 active 分区时才顺带刷新缓存 / 通知(config.persist / config.adopt * 由 store 侧按 key 自行判断); - * 4. 触发时机两处:a) 每次 commit 后;b) **storage 事件** —— 两个 store 的监听器都从「只认 + * 4. 触发时机两处:a) 每次 commit 后;b) **storage 事件** —— 三个 store 的监听器都从「只认 * 当前 active key」放宽为「凡是 op-log 里有记录的 key 都认」。这是 K2 的收敛闭环:任何 * 迟到脏写抹掉本窗的 op,都会以 storage 事件的形式到达本窗,本窗随即在锁内把自己的 * op-log 重新施加到最新状态上 —— 被抹的 op 被重新断言,删除也是 op、同样重新断言; diff --git a/docs/product-rules/model-selector-unified.md b/docs/product-rules/model-selector-unified.md index 0c12be607fd..f1a63375ba0 100644 --- a/docs/product-rules/model-selector-unified.md +++ b/docs/product-rules/model-selector-unified.md @@ -24,15 +24,17 @@ - **A 版是新旧用户唯一的模型选择面板**(2026-09-05)。删除 B 版与样式切换入口,不再读取历史 `xdt:modelPickerLayout:v1` 偏好,升级无需手动切换。仅在老被控端 capabilities-only 时保留兼容列表。固定引擎表单同样使用 A,候选锁定在该表单实际能保存的引擎。 - 全局入口:聊天、新任务、定时任务、IM 默认、Hook 工作目录、Worker、Subagent、视觉桥、辅助模型与插件快问快答复用同一面板。只存模型的入口不开放深度 / Fast 配置;仅存单引擎配置的入口不能选择其他引擎。专用模型白名单不得因界面统一而扩大,远程模型与来源来自被控端目录,不能写入本机记忆。 - 模型选择、配置编辑与“跟随默认”均等待保存结果;失败保留面板与原配置。选模型或跟随默认成功才收起,配置编辑保持浮层打开。无模型记忆表的设置入口编辑非当前模型时,直接应用其完整配置。已有待生效配置时再次远程选模,先展示最新用户选择;运行时当前配置的来源、模型、引擎、深度与 Fast 始终来自同一快照。 -- 结构:搜索框贴顶 → 左侧 rail(★收藏 / 全部 / 各供应商图标) → 右侧列表 → 底部「+配置模型」。 +- 结构:搜索框贴顶 → 左侧 rail(最近 / ★收藏 / 全部 / 各供应商图标) → 右侧列表 → 底部「+配置模型」。 + 「最近」格在 ★ 之上(2026-09-16 裁决),与 ★ 一样常驻,图标用时钟回弹箭头(History),点击进入**独立的最近视图**;空列表时显示引导空态。常规视图(全部 / 各供应商 / 同引擎)**不**陈列最近区(与收藏上下叠放会产生视觉歧义)。 - 列表主体固定高 428px(指定可见行数的入口按其既有高度配置),不随供应商筛选、收藏、搜索或空结果改变高度;窗口空间不足时按可用高度收缩。模型列表与供应商侧栏分别内部滚动,搜索与底部操作保持可见。 - Mobile 从模型详情或来源子页返回时,保留进入前的列表滚动位置、搜索词与来源筛选,不重新初始化列表。iOS 保留原生 Form,Android 保留原列表 SheetSurface;详情使用独立滚动容器,隐藏列表禁止交互与读屏访问,不叠加第二个 Modal。完整关闭后重新打开仍沿用原有初始化规则。 - 宽度自适应:`max-content`,min 300px / max min(460px, 100vw-48px);优先保持紧凑,超出可用宽度的模型名显示省略号,hover title 保留全名,不为显示完整名称继续加宽(2026-09-17 截断原则;2026-09-21 将上限从 420px 调整为 460px,以完整显示「Codex GPT-6 Astra」量级的名称)。字段形态仍绑定字段宽度;视口和锚点可用空间不足时继续收缩。 - 行(双行): - L1:厂商图标 · 模型名(弹性,截断) · 价格档/折扣/订阅徽标 · ☆ · 右侧**常驻三元组**`[引擎图标] 深度 [⚡]` · 选中勾。 - - L2:一句描述(截断)。 + - L2:一句描述(截断)。收藏 / 最近 / 全部视图的 L2 改显 provider 来源(来源详情行,同名模型跨来源价格不同);仅单来源视图显描述。 - 三元组**所有行同构、永远显示**——引擎可见性靠一致的结构位,不靠出错才显示;自定义未收藏的行整组提亮一档(secondary)。 -- 分组:收藏 → 推荐(仅已有任务)→ 供应商分组。推荐区先放当前模型,再放同生效引擎的模型;推荐中的模型从下方供应商组移除,空组不渲染标题或间距。供应商侧栏仍基于完整目录。新任务不展示同引擎推荐。组间顺序遵循「设置 → 模型供应商」的拖动排序(本地会话;device-link 用被控端快照顺序,不套控制端本地序),组内按 group/sortOrder;订阅账号(OpenAI、Anthropic)有账号模型清单时 sortOrder 以账号返回顺序为准,只在目录里有的模型接在其后,目录 sortOrder 仅在拿不到账号清单时兜底,见 [模型排序](../dev-rules/model-catalog-maintenance.md#ordering)。收藏区条目**不**从供应商组中去重移除(收藏是配置副本,模型本体仍在原地)。 +- 分组:收藏 → 推荐(仅已有任务)→ 供应商分组。最近**不是**常规视图里的一区(2026-09-16 实测裁决)——它只在侧栏「最近」格(位于收藏之上,见上一节 rail 说明)单开一页,不参与本行顺序。推荐区先放当前模型,再放同生效引擎的模型;推荐中的模型从下方供应商组移除,空组不渲染标题或间距。供应商侧栏仍基于完整目录。新任务不展示同引擎推荐。组间顺序遵循「设置 → 模型供应商」的拖动排序(本地会话;device-link 用被控端快照顺序,不套控制端本地序),组内按 group/sortOrder;订阅账号(OpenAI、Anthropic)有账号模型清单时 sortOrder 以账号返回顺序为准,只在目录里有的模型接在其后,目录 sortOrder 仅在拿不到账号清单时兜底,见 [模型排序](../dev-rules/model-catalog-maintenance.md#ordering)。收藏区条目**不**从供应商组中去重移除(收藏是配置副本,模型本体仍在原地)。 +- **最近视图(2026-09-16)**:只由侧栏「最近」格进入,按最近一次使用时间倒序陈列最近 5 条**配置副本**(与收藏同一套语义:来源 + 模型 + 引擎 + 深度 + Fast;同一模型不同配置各占一行),行与收藏视图一致(L2 显 provider 来源)。点击一条按该副本的完整配置应用;已标星的行与收藏行同构(带上那条收藏的 uid,选中锚点 / 星标一致)。被收藏的副本标星——先按**来源 + 模型**圈定同一条目,再比解析后的引擎 / 深度 / Fast 一致即点亮(只看三元组会跨型号误命中,点星删错收藏);未收藏点星把这份副本存进收藏,已点亮再点星从收藏里移除。**取消收藏若删的正是当前选中的那份收藏**,与收藏视图完全同一口径:先把该模型的默认配置应用出去再删(否则正在跑的配置与显示分家);这是「删收藏」的通用语义,最近视图不另开一套。该视图是只读流水账:**不提供配置浮层**(改配置走模型行或收藏)——最近行不绑右键 / ←,也不宣告 `ArrowLeft` 快捷键(右键不抢原生菜单)。记录点只有对话里「选择真的应用成功」的那一下(新任务草稿 / 已建任务 / 任务内切换),记的是那一刻生效的整份配置 —— 它表示**选择已被接受并应用**,不等于模型请求已经完成(与 §7 的选择/回复区分同一口径;不为记历史加收费探测或额外请求)。定时任务 / IM 默认 / Bot / Hook / Worker / 子代理与设置页的选择是配置动作,不记;SSH 与 device-link 远程也不记(被控端目录的模型在本机不一定可路由)—— 这只限定「写本机历史」,不限制远程选模与远程收藏。不可路由的条目不删只隐藏,数量不足时从更早的历史补足。首日为空是预期状态,不做事后回填。 - 折扣表达:价格档 `¥/$` 串按折后价连续填充(付费亮、折扣灰),后跟淡染徽标 `↓60%`;详情(title+浮层)写全「折扣中 · 标准价 X · 省 Y%」。中文计价 ¥,英文 $。 供应商图标下方以 22 × 3px 横线显示账号通用周限额的**剩余比例**;图标格统一高 38px,数据更新不改变布局。常态灰色,剩余低于 30% 为警示色,剩余不高于 10% 为严重色,复用额度语义 token。悬停与键盘聚焦的 Tip 显示完整连接名、周剩余百分比和“还有多久重置”。紧凑区域优先倒计时,使用上游重置时间计算,不把已过期窗口推断为已补满。 @@ -66,7 +68,7 @@ - **远程收藏按被控电脑共享**:Desktop 与 Mobile 连接同一台电脑时读取和修改该电脑当前账号的既有收藏。各被控电脑独立,不将控制端的本机收藏套用或自动合并到远端。收藏以单条新增、条件编辑、条件删除同步,等持久化确认后反馈成功;并发修改同一副本时拒绝旧快照覆盖,重读后再编辑。断线、账号切换、旧版不支持时不得声称已同步;不自动重试写操作,也不影响会话和连接恢复。 -- Desktop 行/浮层的 ☆ 是**单向「添加副本」动作**:把当前生效配置(模型+引擎+深度+Fast)拷进收藏区,点亮 0.7s 反馈后恢复;源头行不持有收藏态。重复添加去重。 +- Desktop 行/浮层的 ☆ 是**单向「添加副本」动作**:把当前生效配置(模型+引擎+深度+Fast)拷进收藏区,点亮 0.7s 反馈后恢复;源头行不持有收藏态。重复添加去重。**例外**:最近视图的行(§1.2)是配置副本的流水,星标按同一条目已匹配到的收藏亮/灭并可点星取消(点星=删那条收藏,删除口径与收藏视图一致);普通模型行与收藏行的上述契约不变。 - **Mobile 收藏与参数调整分开**(用户确认交互原型,2026-10-02):按来源、模型、引擎、推理强度与 Fast 完整匹配已保存副本,展示当前配置摘要。空心星标对应「收藏此配置」,保存确认后保持实心星标并显示「已收藏 · 点击取消」;再次点击只删除匹配的副本,所有入口都保持当前配置与详情页,不触发恢复推荐。模型目录 ID 与同一条目的 wire ID 视为同一模型,不把已失效档位或不可用 Fast 自动降级后当成匹配。状态跟随被控端收藏快照,失败保留原状态并允许重试。 - Mobile 从普通模型或收藏进入详情,普通参数调整均保存模型偏好;当前选中模型继续走完整配置应用与取消/失败回滚规则。原收藏不变,配置不再匹配时星标回到空心,并提示「参数已调整,原收藏保留」。再次收藏新增独立副本。从收藏打开时提供「编辑原收藏」,参数先留在编辑草稿,点击「保存收藏」才更新原条目,且不改变正在使用的配置。取消编辑不落库;保存失败保留编辑内容;原收藏被远端修改或删除时拒绝旧编辑覆盖,相同配置去重。实现与回归见 `UnifiedModelPickerSheet.tsx`、`unifiedMobilePicker.test.tsx`、`unifiedMobilePickerView.test.tsx`。 - 每条收藏有**独立锚点 uid**,浮层绑定与删除按 uid 区分;收藏不显示选中底色或勾;选中态只出现在模型本体上。配置编辑仍核对来源、模型、引擎、深度、Fast 与实际配置。同模型多条目互不牵连。 @@ -162,6 +164,11 @@ - 仍**不是用户配置**:不落库、不进 device-link payload、写失败静默吞;丢了只是回落模型行。 - 草稿发送建会话时,仍有效且**有显式来源**的草稿锚点写进该 sessionId 的会话槽(跟随默认路由的会话 `providerId` 为 null,与显式来源的锚点永不相等,存了也打不上勾,故不延续)。 +4. `xdt:recentModels:v1:` —— 「最近」**配置副本**流水: + `{ items: [{ providerId, modelId, agent, effort?, fast?, usedAt }] }`(usedAt 倒序,本机保留 + `RECENT_MODELS_KEEP = 20` 条,视图陈列前 5)。字段归一化与去重身份与收藏**共用同一份** + (modelConfigCopy):同一模型不同配置各占一行;跨窗口按副本身份取 `max(usedAt)` 并入 + (与前三项同一套 op-log 调和,记录 op 幂等且无删除类)。记录写入在对话入口「选择真的应用成功」的回调里:选择是异步的(跨引擎事务 / 远程写),回调落地时若归属已变(登出 / 切本地模式)则整条丢弃,不写进新分区;写基底 = 磁盘真相 ∪ 本窗内存态 —— 上一次写失败(配额 / 私密窗口)时那条记录只活在内存里,不能被下次 `freshState()` 读到的磁盘旧快照顶掉。 - 深度/Fast 的每模型记忆**沿用** `providerModelMemory` `:*` 槽(不迁移不改形——它同时是 device-link wire 形状)。 ### 2.4 选中态与会话创建