diff --git a/apps/desktop/src/main/maker-host/__tests__/model-catalog-override-store.test.ts b/apps/desktop/src/main/maker-host/__tests__/model-catalog-override-store.test.ts index e2cd120ef40..94979d7b994 100644 --- a/apps/desktop/src/main/maker-host/__tests__/model-catalog-override-store.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/model-catalog-override-store.test.ts @@ -10,6 +10,8 @@ import path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; +import { MAX_OVERRIDE_ENTRIES_PER_SECTION } from '../model-plane/localCatalogOverrides.js'; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'model-catalog-overrides-test-')); const owner = { current: 'owner-a' }; @@ -21,8 +23,14 @@ vi.mock('../../appSessionState.js', () => ({ ownerScopedUserDataPath: (name: string) => path.join(tmpDir, owner.current, name), })); -const { MAX_MODEL_CATALOG_OVERRIDE_FILE_BYTES, readModelCatalogOverrides } = - await import('../model-catalog-override-store.js'); +const { + MAX_MODEL_CATALOG_OVERRIDE_FILE_BYTES, + ModelCatalogOverrideLossError, + readModelCatalogOverrides, + readModelCatalogImageInput, + setModelCatalogImageInput, + readModelCatalogThinkingDivergence, +} = await import('../model-catalog-override-store.js'); const patchEntry = { base: { name: 'Renamed' } }; @@ -33,6 +41,167 @@ function writeOwnerFile(value: unknown): string { return file; } +describe('model-catalog-override-store / 图片输入能力声明', () => { + const target = { providerId: 'opencode-go', modelId: 'mimo-v2.6-flash' }; + const key = 'opencode-go:mimo-v2.6-flash'; + + it('写入 true/false 落到 base patch,读回 isCustomized 区分「跟随目录」与「显式声明」', async () => { + owner.current = 'owner-image-write'; + expect(readModelCatalogImageInput(target)).toEqual({ value: null, isCustomized: false }); + + await setModelCatalogImageInput(target, true); + expect(readModelCatalogOverrides().patches[key]?.base?.supportsImageInput).toBe(true); + expect(readModelCatalogImageInput(target)).toEqual({ value: true, isCustomized: true }); + + await setModelCatalogImageInput(target, false); + expect(readModelCatalogImageInput(target)).toEqual({ value: false, isCustomized: true }); + }); + + it('条目限定了 agents 时把目标引擎并进名单,让声明真正生效', async () => { + // 目录合并按 agents 过滤,读回却不检查 —— 不并进 pi 就会「显示已声明、运行期不生效」。 + owner.current = 'owner-image-agents'; + writeOwnerFile({ + version: 1, + patches: { [key]: { agents: ['codex'], base: { name: 'Mimo' } } }, + }); + + await setModelCatalogImageInput({ ...target, agent: 'pi' }, true); + expect(readModelCatalogOverrides().patches[key]?.agents).toEqual(['codex', 'pi']); + }); + + it('已有条目无 agents 限制时不加名单', async () => { + owner.current = 'owner-image-no-agents'; + writeOwnerFile({ version: 1, patches: { [key]: { base: { name: 'Mimo' } } } }); + + await setModelCatalogImageInput({ ...target, agent: 'pi' }, true); + expect(readModelCatalogOverrides().patches[key]?.agents).toBeUndefined(); + }); + + it('写入后条目数超上限时拒绝保存,不留下会被下次读取丢弃的条目', async () => { + owner.current = 'owner-image-limit'; + const full: Record = {}; + for (let i = 0; i < MAX_OVERRIDE_ENTRIES_PER_SECTION; i++) full[`p:model-${i}`] = { base: {} }; + writeOwnerFile({ version: 1, patches: full }); + + await expect( + setModelCatalogImageInput({ providerId: 'p', modelId: 'one-more' }, true), + ).rejects.toThrow(/上限/); + // 文件没被改动:原有 1024 条仍完整 + expect(Object.keys(readModelCatalogOverrides().patches)).toHaveLength( + MAX_OVERRIDE_ENTRIES_PER_SECTION, + ); + }); + + it('value=null 删除该键回到跟随目录,条目空掉时整条删除', async () => { + owner.current = 'owner-image-reset'; + await setModelCatalogImageInput(target, true); + await setModelCatalogImageInput(target, null); + expect(readModelCatalogOverrides().patches[key]).toBeUndefined(); + expect(readModelCatalogImageInput(target)).toEqual({ value: null, isCustomized: false }); + }); + + it('只动 supportsImageInput:同条目其它字段与其它条目原样保留', async () => { + owner.current = 'owner-image-preserve'; + writeOwnerFile({ + version: 1, + patches: { + [key]: { + agents: ['pi'], + base: { name: 'Mimo', contextWindow: 128_000 }, + perAgent: { pi: { maxOutput: 8_192 } }, + }, + 'openai:gpt-6': { base: { name: 'Renamed' } }, + }, + }); + + await setModelCatalogImageInput(target, true); + const entry = readModelCatalogOverrides().patches[key]!; + expect(entry.base).toEqual({ + name: 'Mimo', + contextWindow: 128_000, + supportsImageInput: true, + }); + expect(entry.perAgent).toEqual({ pi: { maxOutput: 8_192 } }); + expect(entry.agents).toEqual(['pi']); + expect(readModelCatalogOverrides().patches['openai:gpt-6']).toEqual({ base: { name: 'Renamed' } }); + + // 复位后条目里仍留着用户自己写的字段,不能被一并删掉。 + await setModelCatalogImageInput(target, null); + expect(readModelCatalogOverrides().patches[key]).toEqual({ + agents: ['pi'], + base: { name: 'Mimo', contextWindow: 128_000 }, + perAgent: { pi: { maxOutput: 8_192 } }, + }); + }); + + it('providerId 里的冒号经 encodeURIComponent 转义,不与 modelId 的分隔符混淆', async () => { + owner.current = 'owner-image-escape'; + await setModelCatalogImageInput({ providerId: 'a:b', modelId: 'm' }, true); + expect(readModelCatalogOverrides().patches['a%3Ab:m']?.base?.supportsImageInput).toBe(true); + }); + + it('一行多个引擎 id 一起写(桥接两端 id 不同,只写一边运行期读不到)', async () => { + owner.current = 'owner-image-row'; + await setModelCatalogImageInput( + [ + { providerId: 'openai', modelId: 'gpt-5.6-sol' }, + { providerId: 'openai', modelId: 'chatgpt/gpt-5.6-sol' }, + ], + true, + ); + const patches = readModelCatalogOverrides().patches; + expect(patches['openai:gpt-5.6-sol']?.base?.supportsImageInput).toBe(true); + expect(patches['openai:chatgpt/gpt-5.6-sol']?.base?.supportsImageInput).toBe(true); + }); + + it('清掉 perAgent 里残留的 supportsImageInput,并让读取按 agent 算有效值', async () => { + // perAgent 会遮蔽 base:不清的话 UI(读 base)显示已声明、运行期(按合并语义)仍拒收。 + owner.current = 'owner-image-peragent'; + writeOwnerFile({ + version: 1, + patches: { + [key]: { agents: ['pi'], perAgent: { pi: { supportsImageInput: false } } }, + }, + }); + // 未写入前:读 pi 的有效值仍是显式声明(不假装「跟随目录」)。 + expect(readModelCatalogImageInput({ ...target, agent: 'pi' })).toEqual({ + value: false, + isCustomized: true, + }); + + await setModelCatalogImageInput({ ...target, agent: 'pi' }, true); + expect(readModelCatalogOverrides().patches[key]?.perAgent).toBeUndefined(); + expect(readModelCatalogImageInput({ ...target, agent: 'pi' })).toEqual({ + value: true, + isCustomized: true, + }); + }); + + it('手改文件里会被 sanitize 丢弃的 patches 条目:拒绝写入而不是静默删掉', async () => { + owner.current = 'owner-image-lossy'; + writeOwnerFile({ + version: 1, + patches: { + // 未知字段 → 整条被 sanitize 隔离;写盘会把 patches 整段换成清洗后的快照。 + 'openai:typo-model': { base: { name: 'Typo', unknownField: 1 } }, + }, + }); + + await expect(setModelCatalogImageInput(target, true)).rejects.toThrow(/无法保留/); + // 独立错误类型让 IPC 层能把它映射成可执行的 PRECONDITION_FAILED(而不是笼统的 INTERNAL)。 + await expect(setModelCatalogImageInput(target, true)).rejects.toBeInstanceOf( + ModelCatalogOverrideLossError, + ); + // 文件没被动过:那条手改还在(修好后下一次写入自动恢复)。 + const raw = JSON.parse( + fs.readFileSync(path.join(tmpDir, owner.current, 'model-catalog-overrides.json'), 'utf8'), + ) as { patches: Record }; + expect(raw.patches['openai:typo-model']).toEqual({ + base: { name: 'Typo', unknownField: 1 }, + }); + }); +}); + describe('model-catalog-override-store', () => { it('读取显式 v1 文件', () => { owner.current = 'owner-valid'; @@ -105,4 +274,49 @@ describe('model-catalog-override-store', () => { expect(fs.statSync(oversized).size).toBeGreaterThan(MAX_MODEL_CATALOG_OVERRIDE_FILE_BYTES); expect(fs.existsSync(oversized)).toBe(true); }); + + it('readModelCatalogThinkingDivergence 把「同集合不同默认档」报成分叉', () => { + // 手改文件留下的分裂:两侧档位集合相同、默认档不同。diverged 不置位的话, + // 抽屉的「重选当前项」会被同值 no-op 挡掉,默认档的分叉永远修不掉。 + owner.current = 'owner-thinking-diverged-default'; + writeOwnerFile({ + version: 1, + patches: { + 'prov:model': { + agents: ['pi', 'codex'], + perAgent: { + pi: { efforts: ['low', 'max'], defaultEffort: 'low' }, + codex: { efforts: ['low', 'max'], defaultEffort: 'max' }, + }, + }, + }, + }); + expect( + readModelCatalogThinkingDivergence([ + { providerId: 'prov', modelId: 'model', agent: 'pi' }, + { providerId: 'prov', modelId: 'model', agent: 'codex' }, + ]), + ).toEqual({ diverged: true }); + + // 默认档一致时不算分叉(集合相同 + 默认档相同)。 + owner.current = 'owner-thinking-consistent'; + writeOwnerFile({ + version: 1, + patches: { + 'prov:model': { + agents: ['pi', 'codex'], + perAgent: { + pi: { efforts: ['low', 'max'], defaultEffort: 'low' }, + codex: { efforts: ['low', 'max'], defaultEffort: 'low' }, + }, + }, + }, + }); + expect( + readModelCatalogThinkingDivergence([ + { providerId: 'prov', modelId: 'model', agent: 'pi' }, + { providerId: 'prov', modelId: 'model', agent: 'codex' }, + ]), + ).toEqual({ diverged: false }); + }); }); diff --git a/apps/desktop/src/main/maker-host/__tests__/modelCatalogThinkingOverride.test.ts b/apps/desktop/src/main/maker-host/__tests__/modelCatalogThinkingOverride.test.ts new file mode 100644 index 00000000000..0c8998e2019 --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/modelCatalogThinkingOverride.test.ts @@ -0,0 +1,217 @@ +/** + * model-catalog-override-store 的思考档位声明面 —— 与图片输入声明同构: + * 三态(跟随 / 自定义档位 / 复位)、只动自己那两个字段、perAgent 残留遮蔽要清掉、 + * 非法组合在写入面就拒。 + * + * 为什么这条面必须存在:目录把「会推理」的模型声明成不支持思考时,Pi 客户端就没有 + * thinking 通道(`piThinkingLevels.mjs`:`reasoning !== true` → 零档位),模型推理只能随 + * content 返回、被 Cindy 当普通 assistant 正文渲染。用户此前没有任何入口能声明这件事。 + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import type { Effort } from '@cindy/model-providers'; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'model-catalog-thinking-test-')); +const owner = { current: 'owner-a' }; + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/never-used-here' } })); +vi.mock('../logger-adapter.js', () => ({ + desktopMakerLogger: { child: () => ({ info: () => {}, warn: () => {}, error: () => {} }) }, +})); +vi.mock('../../appSessionState.js', () => ({ + ownerScopedUserDataPath: (name: string) => path.join(tmpDir, owner.current, name), +})); + +const { + readModelCatalogOverrides, + readModelCatalogThinking, + readModelCatalogThinkingDivergence, + setModelCatalogThinking, +} = await import('../model-catalog-override-store.js'); + +const target = { providerId: 'opencode-go', modelId: 'space-bunny-free' }; +const key = 'opencode-go:space-bunny-free'; +/** 厂商模型卡:5 档、默认 max、强制推理。 */ +const TIERS = ['low', 'medium', 'high', 'xhigh', 'max'] as const; + +function writeOwnerFile(value: unknown): void { + const file = path.join(tmpDir, owner.current, 'model-catalog-overrides.json'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, typeof value === 'string' ? value : JSON.stringify(value), 'utf8'); +} + +describe('model-catalog-override-store / 思考档位声明', () => { + it('写入落到 base patch,读回 isCustomized 区分「跟随目录」与「显式声明」', async () => { + owner.current = 'owner-thinking-write'; + // 未声明时读回不带 defaultEffort 键(没有声明就没有默认档可言)。 + expect(readModelCatalogThinking(target)).toEqual({ value: null, isCustomized: false }); + + await setModelCatalogThinking(target, [...TIERS], 'max'); + expect(readModelCatalogOverrides().patches[key]?.base).toEqual({ + efforts: [...TIERS], + defaultEffort: 'max', + }); + // 读回带上默认档:写入侧靠它沿用既有默认,而不是猜「排序第一档」。 + expect(readModelCatalogThinking(target)).toEqual({ + value: [...TIERS], + defaultEffort: 'max', + isCustomized: true, + }); + + // 换一组档位 = 数组整体替换,不与旧值合并。 + await setModelCatalogThinking(target, ['low', 'high'], 'high'); + expect(readModelCatalogOverrides().patches[key]?.base?.efforts).toEqual(['low', 'high']); + }); + + it('tiers=null 删除该键回到跟随目录;条目空掉时整条删除', async () => { + owner.current = 'owner-thinking-reset'; + await setModelCatalogThinking(target, [...TIERS], 'max'); + await setModelCatalogThinking(target, null); + expect(readModelCatalogOverrides().patches[key]).toBeUndefined(); + // 未声明时读回不带 defaultEffort 键(没有声明就没有默认档可言)。 + expect(readModelCatalogThinking(target)).toEqual({ value: null, isCustomized: false }); + }); + + it('只动 efforts/defaultEffort:同条目其它字段与其它条目原样保留', async () => { + owner.current = 'owner-thinking-preserve'; + writeOwnerFile({ + version: 1, + patches: { + [key]: { + agents: ['pi'], + base: { name: 'Space Bunny', contextWindow: 1_000_000 }, + perAgent: { pi: { maxOutput: 8_192 } }, + }, + 'openai:gpt-6': { base: { name: 'Renamed' } }, + }, + }); + + await setModelCatalogThinking(target, [...TIERS], 'max'); + const entry = readModelCatalogOverrides().patches[key]!; + expect(entry.base).toEqual({ + name: 'Space Bunny', + contextWindow: 1_000_000, + efforts: [...TIERS], + defaultEffort: 'max', + }); + expect(entry.perAgent).toEqual({ pi: { maxOutput: 8_192 } }); + expect(entry.agents).toEqual(['pi']); + expect(readModelCatalogOverrides().patches['openai:gpt-6']).toEqual({ base: { name: 'Renamed' } }); + + // 复位后条目里仍留着用户自己写的字段,不能被一并删掉。 + await setModelCatalogThinking(target, null); + expect(readModelCatalogOverrides().patches[key]).toEqual({ + agents: ['pi'], + base: { name: 'Space Bunny', contextWindow: 1_000_000 }, + perAgent: { pi: { maxOutput: 8_192 } }, + }); + }); + + it('perAgent 里残留的 efforts 会遮蔽 base(UI 显示与运行期不一致),写入时一并清掉', async () => { + owner.current = 'owner-thinking-peragent'; + writeOwnerFile({ + version: 1, + patches: { + [key]: { + perAgent: { pi: { efforts: ['low'], defaultEffort: 'low' } }, + }, + }, + }); + + await setModelCatalogThinking(target, [...TIERS], 'max'); + const entry = readModelCatalogOverrides().patches[key]!; + // base 有值、perAgent 被清空 → 读回不再被遮蔽。 + expect(entry.base?.efforts).toEqual([...TIERS]); + expect(entry.perAgent).toBeUndefined(); + expect(readModelCatalogThinking({ ...target, agent: 'pi' }).value).toEqual([...TIERS]); + }); + + it('按 agent 读回时合成 perAgent 引擎例外(base + perAgent[agent])', async () => { + owner.current = 'owner-thinking-agent-merge'; + writeOwnerFile({ + version: 1, + patches: { + [key]: { + base: { efforts: [...TIERS], defaultEffort: 'max' }, + perAgent: { codex: { efforts: ['low', 'high'], defaultEffort: 'high' } }, + }, + }, + }); + + expect(readModelCatalogThinking({ ...target, agent: 'pi' }).value).toEqual([...TIERS]); + expect(readModelCatalogThinking({ ...target, agent: 'codex' }).value).toEqual(['low', 'high']); + }); + + it('多引擎 id 分叉可被检出,交给 UI 允许「重选当前项」写回一致', async () => { + owner.current = 'owner-thinking-diverge'; + const piTarget = { providerId: 'openai', agent: 'pi' as const, modelId: 'chatgpt/gpt-5.6-sol' }; + const codexTarget = { providerId: 'openai', agent: 'codex' as const, modelId: 'gpt-5.6-sol' }; + + await setModelCatalogThinking(piTarget, [...TIERS], 'max'); + expect(readModelCatalogThinkingDivergence([piTarget, codexTarget]).diverged).toBe(true); + + await setModelCatalogThinking(codexTarget, [...TIERS], 'max'); + expect(readModelCatalogThinkingDivergence([piTarget, codexTarget]).diverged).toBe(false); + }); + + it('空数组 = 显式「不支持思考」必须能存下来(三态的第三态)', async () => { + owner.current = 'owner-thinking-explicit-off'; + // sanitize 明确支持 efforts: [] 配 defaultEffort: null;写入面不能把它当非法输入。 + await setModelCatalogThinking(target, [], null); + // 空集合必须显式带 defaultEffort: null:sanitize 的自洽校验要求「空集合 ⇒ 无默认档」。 + expect(readModelCatalogOverrides().patches[key]?.base).toEqual({ efforts: [], defaultEffort: null }); + expect(readModelCatalogThinking(target)).toMatchObject({ value: [], isCustomized: true }); + + // 复位回「跟随目录」后条目整条删除。 + await setModelCatalogThinking(target, null); + expect(readModelCatalogOverrides().patches[key]).toBeUndefined(); + }); + + it('读回带上默认档,写入侧才能不猜(否则会把厂商默认静默改成排序第一档)', async () => { + owner.current = 'owner-thinking-default-echo'; + await setModelCatalogThinking(target, [...TIERS], 'max'); + // 厂商默认是 max,读回必须原样带回,UI 才能沿用而不是猜 low。 + expect(readModelCatalogThinking(target)).toMatchObject({ + value: [...TIERS], + defaultEffort: 'max', + }); + }); + + it('含 ultra 的声明不会被后续写入静默删掉', async () => { + owner.current = 'owner-thinking-ultra'; + const withUltra: Effort[] = [...TIERS, 'ultra']; + await setModelCatalogThinking(target, withUltra, 'max'); + // 换成不含 ultra 的集合是用户的显式选择,应当照做。 + await setModelCatalogThinking(target, ['low', 'high'], 'low'); + expect(readModelCatalogThinking(target).value).toEqual(['low', 'high']); + // 但仅声明默认档、集合不变时,ultra 必须原样留着。 + await setModelCatalogThinking(target, withUltra, 'max'); + expect(readModelCatalogThinking(target).value).toContain('ultra'); + }); + + it('分叉判定按内容比较:两侧声明完全一致不算分叉', async () => { + owner.current = 'owner-thinking-div-content'; + const piTarget = { providerId: 'openai', agent: 'pi' as const, modelId: 'chatgpt/gpt-5.6-sol' }; + const codexTarget = { providerId: 'openai', agent: 'codex' as const, modelId: 'gpt-5.6-sol' }; + // 两个不同 key 各写一次、内容相同:每次读盘都是新数组,引用比较会永远误判分叉。 + await setModelCatalogThinking(piTarget, [...TIERS], 'max'); + await setModelCatalogThinking(codexTarget, [...TIERS], 'max'); + expect(readModelCatalogThinkingDivergence([piTarget, codexTarget]).diverged).toBe(false); + }); + + it('写入面就拒掉不自洽的档位组合,不让 sanitize 事后把整条判无效', async () => { + owner.current = 'owner-thinking-invalid'; + // 默认档不在档位集合里。 + await expect( + setModelCatalogThinking(target, ['low', 'high'], 'max'), + ).rejects.toThrow(/not in the declared tiers/); + // 空档位集合却带默认档。 + await expect(setModelCatalogThinking(target, [], 'max')).rejects.toThrow(/cannot be empty/); + // 失败的写入不得留下任何痕迹。 + expect(readModelCatalogOverrides().patches[key]).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/main/maker-host/__tests__/modelMetadataLayers.integration.test.ts b/apps/desktop/src/main/maker-host/__tests__/modelMetadataLayers.integration.test.ts index 6f1962f3756..5e961b57546 100644 --- a/apps/desktop/src/main/maker-host/__tests__/modelMetadataLayers.integration.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/modelMetadataLayers.integration.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest'; +import { deriveAvailableModels } from '../catalog-to-descriptors.js'; +import { resolveSendToSessionExecutionConfig } from '../../maker-ipc/sendToSessionExecutionConfig.js'; +import type { OrcaWorkerProviderRoutingContext } from '../../maker-ipc/orcaWorkerCreationService.js'; + it('isolates excess additions and duplicate tags without discarding valid local overrides', () => { const model = (n: number) => ({ id: 'local-' + n, @@ -451,6 +455,76 @@ describe('metadata layers through the active catalog', () => { }); expect(getActiveCatalog().providers.find((p) => p.id === 'xd')?.models.codex).toHaveLength(1); }); + it('clears the main-side valid: none gate once the user declares the effort tiers', () => { + // issue #5762 的实报路径:自定义供应商手填模型 ID、不配任何推理选项。此时 efforts 只是 + // 占位空表,任何显式档位都被 main 准入按 `effort "…" not supported … valid: none` 拒掉。 + // 用户在本 PR 的「思考」档位多选里声明真实档位后,同一份目录投影出去的 efforts 必须 + // 非空 —— store 侧(modelCatalogThinkingOverride)与准入侧(sendToSessionExecutionConfig) + // 各有一半测试,中间的 active-catalog → descriptor → effortMetaByModel 投影此前无人看守, + // 任一侧回归都会让用户在真机上重新撞 valid: none 而单测全绿。 + const runtime = { baseUrl: 'https://relay.example/v1', models: [{ id: 'hand-filled', name: 'Hand filled' }] }; + setCustomProviders([ + buildUserProvider({ id: 'relay', name: 'Relay', runtimes: { pi: runtime } }), + ]); + const available = () => deriveAvailableModels(getActiveCatalog(), 'pi'); + // 与 orcaProviderRoutingContext 同一构造:准入真正读的是 effortMetaByModel, + // 不是 availableModels 上的 efforts(见 sendToSessionExecutionConfig 的 routeEffortMeta)。 + const routing = (): OrcaWorkerProviderRoutingContext => ({ + availability: { + 'claude-code': [], + codex: [], + pi: [{ + id: 'relay', + name: 'Relay', + models: available().map((m) => m.id), + fastModels: [], + effortMetaByModel: Object.fromEntries( + available().map((m) => [ + m.id, + { + efforts: m.efforts, + defaultEffort: m.defaultEffort, + ...(m.effortsUnknown === true ? { effortsUnknown: true } : {}), + }, + ]), + ), + }], + }, + resolveDefaultProviderIdForModel: () => 'relay', + }); + const submit = () => + resolveSendToSessionExecutionConfig({ + source: { + agentKind: 'pi', + model: 'hand-filled', + effort: 'high', + fastMode: false, + providerId: 'relay', + }, + overrides: { effort: 'high' }, + availableModels: available(), + providerRouting: routing(), + hasCindyAiApiKey: true, + }); + + // 基线:占位空表 → 显式 high 被拒(本 issue 的报错原文)。 + const before = submit(); + expect(before).toMatchObject({ ok: false, errorCode: 'INVALID_ARGS' }); + expect(before.ok === false && before.message).toContain( + 'effort "high" not supported by model "hand-filled". valid: none', + ); + + // 声明真实档位后,同一份投影出的 efforts 非空,显式档位原样放行。 + setLocalCatalogOverrides( + sanitizeModelCatalogOverrides({ + version: 1, + patches: { 'relay:hand-filled': { base: { efforts: ['high'] } } }, + }).overrides, + ); + expect(available().find((m) => m.id === 'hand-filled')?.efforts).toEqual(['high']); + expect(submit()).toMatchObject({ ok: true, config: { effort: 'high', model: 'hand-filled' } }); + }); + it('keeps explicit custom Pi values above force and inherits public metadata for an unknown supplier', () => { const r = registry(); setActiveCatalog({ ...BUNDLED_CATALOG, modelRegistry: r }); diff --git a/apps/desktop/src/main/maker-host/__tests__/modelPlane.test.ts b/apps/desktop/src/main/maker-host/__tests__/modelPlane.test.ts index 07d76065c79..776d5031dc2 100644 --- a/apps/desktop/src/main/maker-host/__tests__/modelPlane.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/modelPlane.test.ts @@ -29,6 +29,7 @@ import { setXdGatewayModels, } from '../active-catalog.js'; import { + applyExistingModelLocalPatch, EMPTY_MODEL_CATALOG_OVERRIDES, sanitizeModelCatalogOverrides, type ModelCatalogOverrides, @@ -798,6 +799,57 @@ describe('retired tombstone 与 discovery 回补', () => { }); }); +describe('图片输入能力的本地声明(未声明 → 显式声明)', () => { + // 自定义连接(例如 opencode-go)的模型常常没有声明 supportsImageInput, + // 运行期 pi/index.ts 的 assertImageInputSupported 只认 `=== true`,于是带图消息在 + // 客户端就被拒收。这里证明「写本机 patch」确实能把它变成显式声明 —— + // 用户无需改动连接配置(那样会让 preset 连接脱离官方目录)。 + const customModel = (id: string): CatalogModel => + ({ id, name: id, contextWindow: 200_000, efforts: [], defaultEffort: null }) as CatalogModel; + + const patchFor = (modelId: string, value: boolean): ModelCatalogOverrides => + sanitizeModelCatalogOverrides({ + version: 1, + additions: {}, + patches: { + [`opencode-go:${modelId}`]: { base: { supportsImageInput: value } }, + }, + }).overrides; + + it('把未声明的模型改成显式支持,false 也如实落成明确关闭', () => { + const overrides = patchFor('mimo-v2.6-flash', true); + const patched = applyExistingModelLocalPatch( + 'opencode-go', + 'pi', + customModel('mimo-v2.6-flash'), + overrides, + ); + expect(patched.supportsImageInput).toBe(true); + // 运行期门读的就是这个字段(`=== true` 才放行图片)。 + expect(patched.supportsImageInput === true).toBe(true); + + const denied = applyExistingModelLocalPatch( + 'opencode-go', + 'pi', + customModel('mimo-v2.6-flash'), + patchFor('mimo-v2.6-flash', false), + ); + expect(denied.supportsImageInput).toBe(false); + }); + + it('不影响同 provider 的其它模型,也不影响别的 provider', () => { + const overrides = patchFor('mimo-v2.6-flash', true); + expect( + applyExistingModelLocalPatch('opencode-go', 'pi', customModel('other'), overrides) + .supportsImageInput, + ).toBeUndefined(); + expect( + applyExistingModelLocalPatch('openai', 'pi', customModel('mimo-v2.6-flash'), overrides) + .supportsImageInput, + ).toBeUndefined(); + }); +}); + describe('本地 override(local 永远最高)', () => { it('账号顺序压过 Registry sortOrder,本地 sortOrder patch 仍最高;xAI 双 root 保留声明顺序', () => { setActiveCatalog( diff --git a/apps/desktop/src/main/maker-host/model-catalog-override-store.ts b/apps/desktop/src/main/maker-host/model-catalog-override-store.ts index 42863b7a465..cc2758a9385 100644 --- a/apps/desktop/src/main/maker-host/model-catalog-override-store.ts +++ b/apps/desktop/src/main/maker-host/model-catalog-override-store.ts @@ -15,25 +15,42 @@ * active-catalog 合并,是路由/能力派生的输入,MCP create_worker / scheduler 等 * 无窗口路径也要一致生效,真源必须 main 可靠可读。 * - * 本轮(PR-C)刻意**不加 IPC/写 API**:设置 UI 是后续 PR,现在没有 renderer - * 消费者,提前开 channel 或留死写入口只会扩大 attack surface。用户仍可直接编辑 - * 文件;坏 JSON/超限文件原样保留,修正后下一次同步自动恢复。 + * 写入面:只开「模型级图片输入能力」(setModelCatalogImageInput)与 + * 「模型级思考档位」(setModelCatalogThinking)两组字段——「未声明」与「声明错误」 + * 都是真实状态,用户必须能自己显式声明(见 model-metadata-precedence.md 的字段 + * 继承与用户显式覆盖)。仍然**不开通用 patch 写入口**:那会让任意目录字段可从 + * renderer 改写,超出当前需求;价格、defaultEnabled、routing 依旧不在此列。用户仍 + * 可直接手改文件;坏 JSON/超限文件原样保留,修正后下一次同步自动恢复。 */ +import fs from 'node:fs'; + import { desktopMakerLogger } from './logger-adapter.js'; import { createOverrideSettingsFile } from './override-settings-file.js'; import { EMPTY_MODEL_CATALOG_OVERRIDES, + MAX_OVERRIDE_ENTRIES_PER_SECTION, sanitizeModelCatalogOverrides, + type ModelCatalogOverrideEntry, type ModelCatalogOverrides, } from './model-plane/localCatalogOverrides.js'; import { ownerScopedUserDataPath } from '../appSessionState.js'; +import type { AgentKind, Effort } from '@cindy/model-providers'; +import type { ModelCatalogThinkingView } from '../../shared/modelCatalogThinking.js'; const log = desktopMakerLogger.child('model-catalog-overrides'); /** main 同步读取的硬上限;目录 override 正常只有数 KB。 */ export const MAX_MODEL_CATALOG_OVERRIDE_FILE_BYTES = 1_048_576; +/** + * 写入会丢掉手改条目时抛出(写在锁内、不落盘)。用独立类型而不是普通 Error, + * 让 IPC 层能把它映射成可执行的 PRECONDITION_FAILED(带指引),而不是笼统的 INTERNAL。 + */ +export class ModelCatalogOverrideLossError extends Error { + override readonly name = 'ModelCatalogOverrideLossError'; +} + function normalize(raw: unknown): ModelCatalogOverrides { const { overrides, invalid } = sanitizeModelCatalogOverrides(raw); if (invalid.length > 0) { @@ -46,13 +63,35 @@ function normalize(raw: unknown): ModelCatalogOverrides { } const store = createOverrideSettingsFile({ - filePath: () => ownerScopedUserDataPath('model-catalog-overrides.json'), + filePath: overrideFilePath, defaults: EMPTY_MODEL_CATALOG_OVERRIDES, normalize, log, label: 'model-catalog-overrides', maxBytes: MAX_MODEL_CATALOG_OVERRIDE_FILE_BYTES, preserveUnreadableFile: true, + // patch 只替换被写入键的快照,其余原样落盘:不加这条时 writePatch 会用 sanitize 后的 + // 整段 patches 覆盖原文件,手改引入的未知字段/超限条目会被静默删除。 + mergeOverrides: ({ patch, overrides }) => { + const next: Record = { ...overrides }; + for (const key of Object.keys(patch)) { + const value = patch[key as keyof ModelCatalogOverrides]; + if (key === 'patches' && isPlainObject(value) && Object.keys(value).length === 0) { + delete next.patches; + } else { + next[key] = value; + } + } + // 空段不落盘(与 reset 语义一致:没有条目时不留 `{}` 噪音)。 + for (const section of ['patches', 'additions'] as const) { + if (isPlainObject(next[section]) && Object.keys(next[section]).length === 0) { + delete next[section]; + } + } + // 除 version 外什么都不剩 → 整文件收回「无覆盖」。 + if (Object.keys(next).every((key) => key === 'version')) return {}; + return next; + }, }); /** 当前 override 快照(注入 active-catalog 合并;mtime 守卫让手改文件下次读取生效)。 */ @@ -60,3 +99,327 @@ export function readModelCatalogOverrides(): ModelCatalogOverrides { store.invalidateIfChanged(); return store.read(); } + +/** 模型级图片输入能力的 override 目标。agent 只影响读取时的有效值(base + perAgent 合并)。 */ +export interface ModelCatalogImageInputTarget { + providerId: string; + modelId: string; + agent?: AgentKind; +} + +/** + * 模型级思考档位的 override 目标。形状与图片输入那份**逐字段相同**,语义也相同: + * 写入只用 (providerId, modelId) 拼 key;`agent` 只影响读取时的有效值合成 + * (base + perAgent 合并)与目录成员校验。 + */ +export interface ModelCatalogThinkingStoreTarget { + providerId: string; + modelId: string; + agent?: AgentKind; +} + +function overrideFilePath(): string { + return ownerScopedUserDataPath('model-catalog-overrides.json'); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** patch 条目的键格式,与 sanitize/合并侧保持一致。 */ +function patchKey(providerId: string, modelId: string): string { + return `${encodeURIComponent(providerId)}:${modelId}`; +} + +/** + * 写盘会把 `patches` 整段换成 sanitize 后的快照 —— 手改文件里被隔离的条目(未知字段、 + * 超上限的尾部条目)会被静默丢掉。在锁内現读原文件,发现会丢就显式拒绝,而不是把用户的 + * 手改当垃圾清掉(与 sanitize 的「显式契约」一致)。 + */ +function assertNoPatchEntriesWouldBeLost(): void { + let rawText: string; + try { + rawText = fs.readFileSync(overrideFilePath(), 'utf-8'); + } catch { + // 文件不存在/不可读:没东西可丢;真不可读时 store 自己会拒绝覆写。 + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(rawText); + } catch { + return; // 坏 JSON 由 store 的 preserveUnreadableFile 语义处理。 + } + if (!isPlainObject(parsed) || !isPlainObject(parsed.patches)) return; + const rawPatchKeys = Object.keys(parsed.patches); + if (rawPatchKeys.length === 0) return; + const { overrides, invalid } = sanitizeModelCatalogOverrides(parsed); + const keptPatchKeys = new Set(Object.keys(overrides.patches)); + const lostKeys = rawPatchKeys.filter((key) => !keptPatchKeys.has(key)); + const patchSectionInvalid = invalid.filter( + (entry) => entry === 'patches' || entry.startsWith('patches:'), + ); + if (lostKeys.length > 0 || patchSectionInvalid.length > 0) { + throw new ModelCatalogOverrideLossError( + 'model-catalog-overrides.json 里有本版本无法保留的 patches 条目' + + `(${[...new Set([...lostKeys, ...patchSectionInvalid])].slice(0, 5).join(', ')});` + + '请先修正该文件再在设置里改动图片输入能力,以免手改内容被静默丢弃。', + ); + } +} + +/** + * 条目被限定了作用范围时(如手改的 `agents:['codex']`),把公共字段写进 base + * 对不在名单里的引擎不会生效:目录合并按 agents 过滤,而读回不检查 agents —— + * 抽屉显示「已声明」,运行期仍用旧能力(评审 P1)。把本次目标引擎并进名单, + * 让声明真正生效,而不是静默丢弃。 + * + * 名单为空(无限制)或目标没带 agent 时不做任何事:base 本来就对该条目全部引擎生效。 + */ +function widenAgentsForDeclaredTargets( + entry: ModelCatalogOverrideEntry, + agents: readonly AgentKind[], +): void { + const declared = entry.agents; + if (!declared || agents.length === 0) return; + const missing = agents.filter((agent) => !declared.includes(agent)); + if (missing.length === 0) return; + entry.agents = [...declared, ...missing]; +} + +/** 从条目里拿指定 agent 的有效图片能力声明(perAgent 覆盖 base,与合并侧一致)。 */ +function effectiveImageInput( + entry: ModelCatalogOverrideEntry | undefined, + agent: AgentKind | undefined, +): boolean | undefined { + const perAgentValue = agent ? entry?.perAgent?.[agent]?.supportsImageInput : undefined; + const baseValue = entry?.base?.supportsImageInput; + const value = perAgentValue !== undefined ? perAgentValue : baseValue; + return typeof value === 'boolean' ? value : undefined; +} + +/** + * 声明图片输入能力:`true`/`false` 写 base patch,`null` 删除该覆盖回到「跟随供应商」。 + * 缺字段继承、false 明确关闭,所以三态必须都能表达。 + * + * 写 base 而不是 perAgent:图片能力是公共字段(model-metadata-precedence.md 的公共 + * 字段范围),运行期门按解析后的模型读它;perAgent 留给真正逐引擎不同的场景。 + * 调用方一次传该行的**全部引擎 id**(桥接两端 id 不同,只写主展示引擎会让运行期读不到)。 + * perAgent 里残留的 supportsImageInput 会遮蔽 base(导致 UI 显示与运行期不一致),连同 + * 本次写入一起清掉;条目里其它字段与其它条目原样保留。 + */ +export async function setModelCatalogImageInput( + target: ModelCatalogImageInputTarget | readonly ModelCatalogImageInputTarget[], + value: boolean | null, +): Promise { + const targets = Array.isArray(target) ? target : [target as ModelCatalogImageInputTarget]; + const keys = [...new Set(targets.map((t) => patchKey(t.providerId, t.modelId)))]; + // 一个 key 可能对应多个引擎(调用方传该行的全部引擎),按键归组供下面拓宽作用范围。 + const agentsByKey = new Map(); + for (const t of targets) { + if (!t.agent) continue; + const key = patchKey(t.providerId, t.modelId); + agentsByKey.set(key, [...(agentsByKey.get(key) ?? []), t.agent]); + } + const agentsForKey = (key: string): AgentKind[] => agentsByKey.get(key) ?? []; + return store.updateAtomic((current) => { + assertNoPatchEntriesWouldBeLost(); + const patches: Record = { ...current.value.patches }; + for (const key of keys) { + const entry: ModelCatalogOverrideEntry = { ...patches[key] }; + const base = { ...entry.base }; + if (value === null) delete base.supportsImageInput; + else base.supportsImageInput = value; + if (Object.keys(base).length > 0) entry.base = base; + else delete entry.base; + widenAgentsForDeclaredTargets(entry, agentsForKey(key)); + if (entry.perAgent) { + const perAgent = { ...entry.perAgent }; + for (const agent of Object.keys(perAgent) as AgentKind[]) { + const fields = { ...perAgent[agent] }; + if (fields.supportsImageInput === undefined) continue; + delete fields.supportsImageInput; + if (Object.keys(fields).length > 0) perAgent[agent] = fields; + else delete perAgent[agent]; + } + if (Object.keys(perAgent).length > 0) entry.perAgent = perAgent; + else delete entry.perAgent; + } + // 条目空掉就整条删除:不留 "{} 条目" 噪音,也让文件能回到"无覆盖"状态。 + if (entry.base || entry.perAgent || entry.agents) patches[key] = entry; + else delete patches[key]; + } + assertPatchCountWithinLimit(patches); + return { patches }; + }); +} + +/** + * 写入面挡住条目数超限:sanitize 只保留按键排序后的前 MAX_OVERRIDE_ENTRIES_PER_SECTION 条, + * 超出的尾部(含本次新写的)下次读就被丢掉,且丢失检查随后会把文件判为不可写, + * 用户必须手改文件才能继续在设置里保存(评审 P2)。这里提前拒绝本次保存。 + */ +function assertPatchCountWithinLimit(patches: Record): void { + const count = Object.keys(patches).length; + if (count <= MAX_OVERRIDE_ENTRIES_PER_SECTION) return; + throw new ModelCatalogOverrideLossError( + `model-catalog-overrides.json 的 patches 条目已达上限 ${MAX_OVERRIDE_ENTRIES_PER_SECTION},` + + `本次保存后将有 ${count} 条;超出上限的条目下次启动就会被丢弃。` + + '请先在文件里删掉不再需要的条目再保存。', + ); +} + +/** + * 读该模型的图片输入 override。`isCustomized` 让 UI 能区分「跟随供应商」与 + * 「显式声明了一个刚好等于目录的值」—— 只报 value 会让「恢复跟随供应商」无从表达。 + * 给了 agent 就按合并语义算有效值(base + perAgent[agent]),否则只看 base。 + */ +export function readModelCatalogImageInput(target: ModelCatalogImageInputTarget): { + value: boolean | null; + isCustomized: boolean; +} { + const overrides = readModelCatalogOverrides(); + const entry = overrides.patches[patchKey(target.providerId, target.modelId)]; + const value = effectiveImageInput(entry, target.agent); + return value === undefined ? { value: null, isCustomized: false } : { value, isCustomized: true }; +} + + +/** 从条目里拿指定 agent 的有效思考档位声明(perAgent 覆盖 base,与合并侧一致)。 */ +function effectiveThinking( + entry: ModelCatalogOverrideEntry | undefined, + agent: AgentKind | undefined, +): { efforts: Effort[]; defaultEffort: Effort | null } | undefined { + const perAgent = agent ? entry?.perAgent?.[agent] : undefined; + const fields = { ...entry?.base, ...perAgent }; + if (fields.efforts === undefined) return undefined; + return { efforts: fields.efforts, defaultEffort: fields.defaultEffort ?? null }; +} + +/** + * 声明思考档位:`efforts` 写 base patch,`null` 删除该覆盖回到「跟随供应商」。 + * 缺字段继承、数组整体替换,所以「跟随 / 自定义档位 / 明确不支持」三态都能表达。 + * + * 为什么要这条:目录里的档位决定 Pi 能不能开真正的 thinking 通道 + * (`piThinkingLevels.mjs`:`reasoning !== true` → 零档位)。上游把会推理的模型声明成 + * 「不支持思考」时,模型推理只能随 content 返回、被 Cindy 当**普通 assistant 正文** + * 渲染(实报:协同 worker 切到该模型后正文变成推理叙述)。这条让用户自己把事实补回来。 + * + * `defaultEffort` 缺省时由 sanitize 侧的「继承宿主默认」语义处理,落到目录当前默认档。 + * 调用方一次传该行的**全部引擎 id**(桥接两端 id 不同,只写主展示引擎会让运行期读不到)。 + * perAgent 里残留的 efforts/defaultEffort 会遮蔽 base(UI 显示与运行期不一致),连同本次 + * 写入一起清掉;条目里其它字段与其它条目原样保留。 + */ +export async function setModelCatalogThinking( + target: ModelCatalogThinkingStoreTarget | readonly ModelCatalogThinkingStoreTarget[], + value: readonly Effort[] | null, + defaultEffort?: Effort | null, +): Promise { + const targets = Array.isArray(target) ? target : [target as ModelCatalogThinkingStoreTarget]; + const keys = [...new Set(targets.map((t) => patchKey(t.providerId, t.modelId)))]; + const agentsByKey = new Map(); + for (const t of targets) { + if (!t.agent) continue; + const key = patchKey(t.providerId, t.modelId); + agentsByKey.set(key, [...(agentsByKey.get(key) ?? []), t.agent]); + } + const agentsForKey = (key: string): AgentKind[] => agentsByKey.get(key) ?? []; + // 档位与默认档的自洽性在写入面就挡住:非法组合会让 sanitize 整条判无效,用户看着 + // "保存了但没生效",而真正原因(档位不自洽)不会出现在任何界面上。 + if (value !== null) { + if (value.length === 0 && defaultEffort) { + throw new Error('thinking tiers cannot be empty while a default tier is set'); + } + if (value.length > 0 && defaultEffort && !value.includes(defaultEffort)) { + throw new Error(`default tier '${defaultEffort}' is not in the declared tiers`); + } + } + return store.updateAtomic((current) => { + assertNoPatchEntriesWouldBeLost(); + const patches: Record = { ...current.value.patches }; + for (const key of keys) { + const entry: ModelCatalogOverrideEntry = { ...patches[key] }; + const base = { ...entry.base }; + if (value === null) { + delete base.efforts; + delete base.defaultEffort; + } else { + base.efforts = [...value]; + if (defaultEffort !== undefined) base.defaultEffort = defaultEffort; + else delete base.defaultEffort; + } + if (Object.keys(base).length > 0) entry.base = base; + else delete entry.base; + widenAgentsForDeclaredTargets(entry, agentsForKey(key)); + if (entry.perAgent) { + const perAgent = { ...entry.perAgent }; + for (const agent of Object.keys(perAgent) as AgentKind[]) { + const fields = { ...perAgent[agent] }; + if (fields.efforts === undefined && fields.defaultEffort === undefined) continue; + delete fields.efforts; + delete fields.defaultEffort; + if (Object.keys(fields).length > 0) perAgent[agent] = fields; + else delete perAgent[agent]; + } + if (Object.keys(perAgent).length > 0) entry.perAgent = perAgent; + else delete entry.perAgent; + } + // 条目空掉就整条删除:不留 "{} 条目" 噪音,也让文件能回到"无覆盖"状态。 + if (entry.base || entry.perAgent || entry.agents) patches[key] = entry; + else delete patches[key]; + } + assertPatchCountWithinLimit(patches); + return { patches }; + }); +} + +/** + * 读该模型的思考档位 override。`isCustomized` 让 UI 能区分「跟随供应商」与 + * 「显式声明了一个刚好等于目录的值」—— 只报 value 会让「恢复跟随供应商」无从表达。 + * 给了 agent 就按合并语义算有效值(base + perAgent[agent]),否则只看 base。 + */ +export function readModelCatalogThinking(target: ModelCatalogThinkingStoreTarget): ModelCatalogThinkingView { + const overrides = readModelCatalogOverrides(); + const entry = overrides.patches[patchKey(target.providerId, target.modelId)]; + const value = effectiveThinking(entry, target.agent); + return value === undefined + ? { value: null, isCustomized: false } + : { value: value.efforts, defaultEffort: value.defaultEffort, isCustomized: true }; +} + +/** + * 该行各引擎 id 的声明是否**不一致**(手工改文件 / 旧版单键写入留下的存量数据)。 + * 展示值取运行期真正消费档位的引擎(Pi)那一侧,但 UI 必须允许用户「重选当前项」把分裂 + * 写回一致 —— 否则同值 no-op 会让分叉永远修不掉(与图片输入 override 同口径)。 + */ +export function readModelCatalogThinkingDivergence( + targets: readonly ModelCatalogThinkingStoreTarget[], +): { diverged: boolean } { + const overrides = readModelCatalogOverrides(); + const views = targets.map((target) => { + const declared = effectiveThinking( + overrides.patches[patchKey(target.providerId, target.modelId)], + target.agent, + ); + // 「一边声明、一边跟随目录」也是分叉:运行期真正消费档位的那一侧可能正好是跟随的那侧, + // 用户却以为已经声明。与图片输入 override 的判定同口径。 + return { + // 按内容比而不是按引用:每次读盘都会造新数组,引用比较会把「两侧声明一致」误判成分叉。 + tiers: declared ? JSON.stringify(declared.efforts) : null, + // 默认档也参与分叉判定:两侧集合相同但默认档不同(手改文件留下的分裂)时, + // 「重选当前项」同样要能放行,否则默认档的分叉永远修不掉。 + defaultEffort: declared ? (declared.defaultEffort ?? null) : null, + customized: declared !== undefined, + }; + }); + if (views.length <= 1) return { diverged: false }; + const head = views[0]!; + return { + diverged: views.some( + (view) => + view.tiers !== head.tiers || + view.defaultEffort !== head.defaultEffort || + view.customized !== head.customized, + ), + }; +} diff --git a/apps/desktop/src/main/maker-host/pi-host.ts b/apps/desktop/src/main/maker-host/pi-host.ts index 2dbc006a4eb..652ba25549a 100644 --- a/apps/desktop/src/main/maker-host/pi-host.ts +++ b/apps/desktop/src/main/maker-host/pi-host.ts @@ -2104,6 +2104,45 @@ export function buildPiAgent(opts: BuildPiAgentOpts): PiAgent | null { }, registerPiProxySession, resolvePiNativeProviders: (ctx) => resolvePiNativeProviders(ctx), + // 旧会话切模时的能力对账读这里:活动目录已含本机 override 合并(applyExistingModelLocalPatch + // 在末位,local 永远最高),所以用户刚声明的「图片输入」能被读到。查不到(provider/model + // 不在目录)返回 undefined → 调用方按「未声明」不动会话快照。 + readModelImageInput: (providerId, modelId) => + getActiveCatalog() + .providers.find((provider) => provider.id === providerId) + ?.models.pi?.find((model) => model.id === modelId)?.supportsImageInput, + // 目录未声明时的**最终结论**(与 resolvePiNativeProviders 的 input 映射同一口径: + // supportsImageInput 未声明 → 默认支持,显式 false → 不支持)。能力对账靠它区分 + // 「用户改回跟随供应商,该恢复支持」与「目录自己标了不支持,应保持纯文本」—— 这两种 + // 状态在 supportsImageInput 上都是 undefined,只有最终 input 能分辨。 + // provider/model 不在目录里 → undefined,调用方保持现状不翻转。 + readModelCatalogImageCapability: (providerId, modelId) => { + const model = getActiveCatalog() + .providers.find((provider) => provider.id === providerId) + ?.models.pi?.find((entry) => entry.id === modelId); + if (!model) return undefined; + if (model.supportsImageInput !== undefined) return model.supportsImageInput; + // 未声明 → 落到默认结论。口径与 resolvePiNativeProviders 的 input 映射一致 + // (`supportsImageInput === undefined ? template?.input ?? ['text','image']`): + // 目录若用 modalities.input 明确列了能力就以它为准,否则默认支持。 + const declaredInput = model.modalities?.input; + return declaredInput === undefined ? true : declaredInput.includes('image'); + }, + // 该模型在活动目录里最终的思考档位集合(目录与本机 override 合并后的结论)。 + // 活会话的 activeEffortSnapshot 是启动时解析的,Pi 的 set_model 不重读 models.json, + // 用户声明档位后必须靠这个钩子在切模点对齐,否则 thinking 通道开不出来。 + // provider/model 不在目录里 → undefined,调用方保持现状不猜。 + readModelCatalogThinkingTiers: (providerId, modelId) => { + const model = getActiveCatalog() + .providers.find((provider) => provider.id === providerId) + ?.models.pi?.find((entry) => entry.id === modelId); + // 模型不在目录里 → 拿不到结论,调用方保持快照不变。 + if (!model) return undefined; + // `efforts: []` 是**有效结论**:用户在设置里显式声明了「该模型不支持思考」。 + // 把它折成 undefined 会让对账退回「保持旧快照」,于是刚保存的关闭声明在当前 + // 会话不生效(仍按启动时的非空档位继续发 set_thinking_level)。 + return [...model.efforts]; + }, resolvePiRuntimeModelDescriptor: opts.resolvePiRuntimeModelDescriptor, resolvePiRuntimeModels: opts.resolvePiRuntimeModels, resolvePiGatewayModelDescriptor: opts.resolvePiGatewayModelDescriptor, diff --git a/apps/desktop/src/main/maker-ipc/__tests__/providerHandlers.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/providerHandlers.test.ts index 5b21fb658ba..b753c5bd306 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/providerHandlers.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/providerHandlers.test.ts @@ -3579,6 +3579,390 @@ describe('provider:custom:* CRUD handlers', () => { }); }); +describe('model catalog thinking handlers', () => { + const target = { providerId: 'opencode-go', agent: 'pi' as const, modelId: 'space-bunny-free' }; + + function thinkingDeps(over: Partial = {}): ProviderHandlerDeps { + return makeDeps({ + listProviders: async () => [catalogView('opencode-go', { pi: ['space-bunny-free'] })], + readModelCatalogThinking: vi.fn(() => ({ value: null, isCustomized: false })), + writeModelCatalogThinking: vi.fn(async () => {}), + syncLocalCatalogOverrides: vi.fn(() => {}), + ...over, + }); + } + + it('writes the declared tiers, re-syncs the catalog and broadcasts', async () => { + const harness = new IpcHarness(); + const deps = thinkingDeps(); + registerProviderHandlers(harness, deps); + + await harness.invoke(MAKER_INVOKE.MODEL_CATALOG_THINKING_SET, { + ...target, + value: ['low', 'medium', 'high', 'xhigh', 'max'], + defaultEffort: 'max', + }); + expect(deps.writeModelCatalogThinking).toHaveBeenCalledWith( + [target], + ['low', 'medium', 'high', 'xhigh', 'max'], + 'max', + ); + // 写盘不会自动生效:必须重读 override 注入活动目录并刷新/广播。 + expect(deps.syncLocalCatalogOverrides).toHaveBeenCalledTimes(1); + expect(deps.broadcastChanged).toHaveBeenCalledTimes(1); + }); + + it('passes null through as the "follow the catalog" reset without a membership check', async () => { + const harness = new IpcHarness(); + const deps = thinkingDeps(); + registerProviderHandlers(harness, deps); + + // 目录漂移后指向已下架 id 的陈旧 override 必须能清掉,因此复位不做成员校验。 + await harness.invoke(MAKER_INVOKE.MODEL_CATALOG_THINKING_SET, { + ...target, + modelId: 'retired-model', + value: null, + }); + expect(deps.writeModelCatalogThinking).toHaveBeenCalledWith( + [{ providerId: 'opencode-go', agent: 'pi', modelId: 'retired-model' }], + null, + null, + ); + }); + + it('rejects enterprise-managed providers so the organization stays authoritative', async () => { + // 组织托管供应商的能力由管理员下发;override 是本机最高优先级,不加这道门就能让运行期 + // 能力偏离企业配置。价格 override 已有同款拒绝,这里必须对齐。 + const harness = new IpcHarness(); + const deps = thinkingDeps({ + listProviders: async () => [catalogView('corp-gateway', { pi: ['corp-model'] })], + isOrganizationManagedProviderId: (providerId: string) => providerId === 'corp-gateway', + }); + registerProviderHandlers(harness, deps); + + await expect( + harness.invoke(MAKER_INVOKE.MODEL_CATALOG_THINKING_SET, { + ...target, + providerId: 'corp-gateway', + modelId: 'corp-model', + value: ['high'], + defaultEffort: 'high', + }), + ).rejects.toThrow(/PERMISSION_DENIED.*managed by your organization/); + expect(deps.writeModelCatalogThinking).not.toHaveBeenCalled(); + }); + + it('rejects gateway rows and self-inconsistent tier sets at the IPC boundary', async () => { + const harness = new IpcHarness(); + const deps = thinkingDeps(); + registerProviderHandlers(harness, deps); + + // 网关模型的能力由服务端目录控制。 + await expect( + harness.invoke(MAKER_INVOKE.MODEL_CATALOG_THINKING_SET, { + ...target, + providerId: 'xd', + value: ['high'], + defaultEffort: 'high', + }), + ).rejects.toThrow(/INVALID_PARAMS.*server-controlled/); + + // 默认档不在声明的档位集合里 → 写下去只会被 sanitize 判整条无效,必须在边界就拒。 + await expect( + harness.invoke(MAKER_INVOKE.MODEL_CATALOG_THINKING_SET, { + ...target, + value: ['low', 'high'], + defaultEffort: 'max', + }), + ).rejects.toThrow(/INVALID_PARAMS.*must be one of the declared tiers/); + + // 非法档位名 / 重复档位 / 空集合。 + await expect( + harness.invoke(MAKER_INVOKE.MODEL_CATALOG_THINKING_SET, { ...target, value: ['turbo'] }), + ).rejects.toThrow(/INVALID_PARAMS.*invalid thinking tier/); + await expect( + harness.invoke(MAKER_INVOKE.MODEL_CATALOG_THINKING_SET, { ...target, value: ['low', 'low'] }), + ).rejects.toThrow(/INVALID_PARAMS.*must not repeat/); + expect(deps.writeModelCatalogThinking).not.toHaveBeenCalled(); + }); + + it('accepts an empty tier array as the explicit "no thinking" state', async () => { + // 三态的第三态必须能存下来:底层 sanitize 支持 efforts: [] 配 defaultEffort: null, + // 把它当非法输入会让用户永远无法声明「该模型不支持思考」。 + const harness = new IpcHarness(); + const deps = thinkingDeps(); + registerProviderHandlers(harness, deps); + + await harness.invoke(MAKER_INVOKE.MODEL_CATALOG_THINKING_SET, { + ...target, + value: [], + defaultEffort: null, + }); + expect(deps.writeModelCatalogThinking).toHaveBeenCalledWith([target], [], null); + }); + + it('rejects a default tier that is absent from an explicitly empty declaration', async () => { + const harness = new IpcHarness(); + const deps = thinkingDeps(); + registerProviderHandlers(harness, deps); + + await expect( + harness.invoke(MAKER_INVOKE.MODEL_CATALOG_THINKING_SET, { + ...target, + value: [], + defaultEffort: 'high', + }), + ).rejects.toThrow(/INVALID_PARAMS.*must be one of the declared tiers/); + expect(deps.writeModelCatalogThinking).not.toHaveBeenCalled(); + }); + + it('reads every engine id of the row and prefers the Pi side for display', async () => { + const harness = new IpcHarness(); + const read = vi.fn((t: { agent?: string }) => + t.agent === 'pi' + ? { value: ['low', 'high'], isCustomized: true } + : { value: null, isCustomized: false }, + ); + const deps = thinkingDeps({ readModelCatalogThinking: read as never }); + registerProviderHandlers(harness, deps); + + const view = await harness.invoke(MAKER_INVOKE.MODEL_CATALOG_THINKING_GET, { + ...target, + relatedTargets: [{ providerId: 'opencode-go', agent: 'codex', modelId: 'gpt-5.6-sol' }], + }); + // 展示值取运行期真正消费 thinking 档位的那一侧(Pi),并把「一边声明、一边跟随」报成分叉。 + expect(view).toMatchObject({ value: ['low', 'high'], isCustomized: true, diverged: true }); + }); + + it('reports divergence when only the default tier differs across engines', async () => { + // 两侧档位集合相同、默认档不同(手改文件留下的分裂):diverged 不置位会让「重选当前项」 + // 被同值 no-op 挡掉,默认档的分叉永远修不掉。 + const harness = new IpcHarness(); + const read = vi.fn((t: { agent?: string }) => + t.agent === 'pi' + ? { value: ['low', 'max'], defaultEffort: 'low', isCustomized: true } + : { value: ['low', 'max'], defaultEffort: 'max', isCustomized: true }, + ); + const deps = thinkingDeps({ readModelCatalogThinking: read as never }); + registerProviderHandlers(harness, deps); + + const view = await harness.invoke(MAKER_INVOKE.MODEL_CATALOG_THINKING_GET, { + ...target, + relatedTargets: [{ providerId: 'opencode-go', agent: 'codex', modelId: 'gpt-5.6-sol' }], + }); + expect(view).toMatchObject({ + value: ['low', 'max'], + defaultEffort: 'low', + isCustomized: true, + diverged: true, + }); + }); +}); + +describe('model catalog image input handlers', () => { + const target = { providerId: 'opencode-go', agent: 'pi' as const, modelId: 'mimo-v2.6-flash' }; + + function imageDeps(over: Partial = {}): ProviderHandlerDeps { + return makeDeps({ + listProviders: async () => [catalogView('opencode-go', { pi: ['mimo-v2.6-flash'] })], + readModelCatalogImageInput: vi.fn(() => ({ value: null, isCustomized: false })), + writeModelCatalogImageInput: vi.fn(async () => {}), + syncLocalCatalogOverrides: vi.fn(() => {}), + ...over, + }); + } + + it('writes the declaration, re-syncs the catalog and broadcasts', async () => { + const harness = new IpcHarness(); + const deps = imageDeps(); + registerProviderHandlers(harness, deps); + + await harness.invoke(MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_SET, { ...target, value: true }); + expect(deps.writeModelCatalogImageInput).toHaveBeenCalledWith([target], true); + // 写盘不会自动生效:必须重读 override 注入活动目录并刷新/广播。 + expect(deps.syncLocalCatalogOverrides).toHaveBeenCalledTimes(1); + expect(deps.refreshCatalog).toHaveBeenCalledTimes(1); + // 能力变更必须走 PROVIDER_CHANGED,renderer 靠它重拉 provider 视图。 + expect(deps.broadcastChanged).toHaveBeenCalledTimes(1); + expect(deps.broadcastPricingChanged).not.toHaveBeenCalled(); + }); + + it('passes null through as the "follow the catalog" reset', async () => { + const harness = new IpcHarness(); + const deps = imageDeps(); + registerProviderHandlers(harness, deps); + + await harness.invoke(MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_SET, { ...target, value: null }); + expect(deps.writeModelCatalogImageInput).toHaveBeenCalledWith([target], null); + }); + + it('writes every harness id of the row so the Pi-side key is covered', async () => { + // 桥接投影两端 id 不同(openai 行:codex 用 gpt-5.6-sol、pi 用 chatgpt/gpt-5.6-sol)。 + // override 按 providerId:modelId 精确匹配,只写主展示引擎的 id 时运行期(读 pi 侧 id) + // 完全看不到声明,UI 却会显示「已声明」。 + const harness = new IpcHarness(); + const deps = imageDeps({ + listProviders: async () => [ + catalogView('openai', { codex: ['gpt-5.6-sol'], pi: ['chatgpt/gpt-5.6-sol'] }), + ], + }); + registerProviderHandlers(harness, deps); + + const codexTarget = { providerId: 'openai', agent: 'codex' as const, modelId: 'gpt-5.6-sol' }; + const piTarget = { providerId: 'openai', agent: 'pi' as const, modelId: 'chatgpt/gpt-5.6-sol' }; + await harness.invoke(MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_SET, { + ...codexTarget, + relatedTargets: [piTarget], + value: true, + }); + expect(deps.writeModelCatalogImageInput).toHaveBeenCalledWith([codexTarget, piTarget], true); + }); + + it('clears a stale declaration without requiring catalog membership', async () => { + // value=null 是「恢复跟随目录」:目录漂移(模型下架)后 UI 仍要能清掉陈旧 override。 + const harness = new IpcHarness(); + const deps = imageDeps({ listProviders: async () => [] }); + registerProviderHandlers(harness, deps); + + await harness.invoke(MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_SET, { ...target, value: null }); + expect(deps.writeModelCatalogImageInput).toHaveBeenCalledWith([target], null); + }); + + it('rejects the gateway provider even though the button is disabled in the UI', async () => { + // 按钮 disable 挡不住受信 renderer 直接调 preload:网关能力由服务端目录控制。 + const harness = new IpcHarness(); + const deps = imageDeps({ + listProviders: async () => [catalogView('xd', { pi: ['mimo-v2.6-flash'] })], + }); + registerProviderHandlers(harness, deps); + + await expect( + harness.invoke(MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_SET, { + providerId: 'xd', + agent: 'pi', + modelId: 'mimo-v2.6-flash', + value: true, + }), + ).rejects.toThrow(/INVALID_PARAMS/); + expect(deps.writeModelCatalogImageInput).not.toHaveBeenCalled(); + }); + + it('surfaces the "refuse rather than drop hand-edited entries" guard as an actionable precondition', async () => { + // 写入会静默丢掉手改条目时 store 拒绝落盘;不能退化成笼统的「保存失败」——用户需要知道 + // 该去修哪个文件。PRECONDITION_FAILED 携带 store 给出的指引。 + const { ModelCatalogOverrideLossError } = await import( + '../../maker-host/model-catalog-override-store.js' + ); + const harness = new IpcHarness(); + const deps = imageDeps({ + writeModelCatalogImageInput: vi.fn(async () => { + throw new ModelCatalogOverrideLossError('请先修正 model-catalog-overrides.json'); + }), + }); + registerProviderHandlers(harness, deps); + + await expect( + harness.invoke(MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_SET, { ...target, value: true }), + ).rejects.toThrow(/PRECONDITION_FAILED.*请先修正/); + expect(deps.broadcastChanged).not.toHaveBeenCalled(); + }); + + it('rejects enterprise-managed providers so the organization stays authoritative', async () => { + // 组织托管供应商的能力由管理员下发;override 是本机最高优先级,不加这道门就能让运行期 + // 能力偏离企业配置。价格 override 已有同款拒绝,这里必须对齐。 + const harness = new IpcHarness(); + const deps = imageDeps({ + listProviders: async () => [ + catalogView('corp-gateway', { pi: ['corp-model'] }), + ], + isOrganizationManagedProviderId: (providerId: string) => providerId === 'corp-gateway', + }); + registerProviderHandlers(harness, deps); + + await expect( + harness.invoke(MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_SET, { + ...target, + providerId: 'corp-gateway', + modelId: 'corp-model', + value: true, + }), + ).rejects.toThrow(/PERMISSION_DENIED.*managed by your organization/); + expect(deps.writeModelCatalogImageInput).not.toHaveBeenCalled(); + }); + + it('reports a split declaration and shows the runtime-effective (Pi) value', async () => { + // 存量数据:手工改文件或旧版单键写入可能让同一行两个引擎键各存一个值。展示值必须取运行期 + // 真正消费该能力的 Pi 那一侧,否则 UI 显示“支持”而 Pi 实际拒收;同时用 diverged 标出分叉, + // 让抽屉允许“重选当前项”把两个键写回一致。 + const harness = new IpcHarness(); + const piTarget = { providerId: 'openai', agent: 'pi' as const, modelId: 'chatgpt/gpt-5.6-sol' }; + const codexTarget = { providerId: 'openai', agent: 'codex' as const, modelId: 'gpt-5.6-sol' }; + const deps = imageDeps({ + readModelCatalogImageInput: vi.fn((candidate) => + candidate.modelId === 'chatgpt/gpt-5.6-sol' + ? { value: true, isCustomized: true } + : { value: false, isCustomized: true }, + ), + }); + registerProviderHandlers(harness, deps); + + await expect( + harness.invoke(MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_GET, { + ...codexTarget, + relatedTargets: [piTarget], + }), + ).resolves.toEqual({ value: true, isCustomized: true, diverged: true }); + }); + + it('does not flag divergence when every engine key agrees', async () => { + const harness = new IpcHarness(); + const deps = imageDeps({ + readModelCatalogImageInput: vi.fn(() => ({ value: true, isCustomized: true })), + }); + registerProviderHandlers(harness, deps); + + await expect( + harness.invoke(MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_GET, { + ...target, + relatedTargets: [{ ...target, agent: 'codex' as const }], + }), + ).resolves.toEqual({ value: true, isCustomized: true }); + }); + + it('reads without a catalog membership check so stale overrides stay visible and clearable', async () => { + const harness = new IpcHarness(); + const deps = imageDeps({ listProviders: async () => [] }); + registerProviderHandlers(harness, deps); + + await expect(harness.invoke(MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_GET, target)).resolves.toEqual({ + value: null, + isCustomized: false, + }); + }); + + it('rejects a non-catalog target, a malformed value, and an unwired host', async () => { + const harness = new IpcHarness(); + registerProviderHandlers(harness, imageDeps()); + + await expect( + harness.invoke(MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_SET, { + ...target, + modelId: 'missing', + value: true, + }), + ).rejects.toThrow(/INVALID_PARAMS/); + await expect( + harness.invoke(MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_SET, { ...target, value: 'yes' }), + ).rejects.toThrow(/INVALID_PARAMS/); + + const bare = new IpcHarness(); + registerProviderHandlers(bare, makeDeps()); + await expect(bare.invoke(MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_GET, target)).rejects.toThrow( + /INTERNAL/, + ); + }); +}); + describe('model price override handlers', () => { const target = { providerId: 'openrouter', diff --git a/apps/desktop/src/main/maker-ipc/channels.ts b/apps/desktop/src/main/maker-ipc/channels.ts index d02d95f8747..70f1f71ea59 100644 --- a/apps/desktop/src/main/maker-ipc/channels.ts +++ b/apps/desktop/src/main/maker-ipc/channels.ts @@ -312,6 +312,17 @@ export const MAKER_INVOKE = { MODEL_COMPACTION_SET: 'maker:model-compaction:set', MODEL_CONTEXT_LIMIT_SET: 'maker:model-context-limit:set', MODEL_CONTEXT_LIMIT_RESET: 'maker:model-context-limit:reset', + /** + * 单模型图片输入能力的本地 override(设置 → 模型 → 高级设置)。 + * 上游未声明该能力时「未知」是真实状态,用户必须能显式声明支持/不支持; + * 写入本机目录 override(不写回连接配置,preset 连接因此仍跟随官方目录)。 + * value=null 表示删除 override、回到跟随供应商。设置类写操作:仅本机主页面可调, + * **不进 device-link allowlist**(远程改被控端全局设置越权)。 + */ + MODEL_CATALOG_THINKING_GET: 'maker:model-catalog-thinking:get', + MODEL_CATALOG_THINKING_SET: 'maker:model-catalog-thinking:set', + MODEL_CATALOG_IMAGE_INPUT_GET: 'maker:model-catalog-image-input:get', + MODEL_CATALOG_IMAGE_INPUT_SET: 'maker:model-catalog-image-input:set', // 附加只读引用目录 — 走 closure 推送; DB 持久化由 renderer 同步调 // local-db:sessions:update (跟 SET_MODEL / sessionService.update 双 IPC 协调先例一致) SET_EXTRA_DIRS: 'maker:set-extra-dirs', diff --git a/apps/desktop/src/main/maker-ipc/providerHandlers.ts b/apps/desktop/src/main/maker-ipc/providerHandlers.ts index 1b582b6425b..93448473cf6 100644 --- a/apps/desktop/src/main/maker-ipc/providerHandlers.ts +++ b/apps/desktop/src/main/maker-ipc/providerHandlers.ts @@ -3,6 +3,7 @@ import { setProviderPresentation, retainProviderPresentationAfterAuthChange } fr import type { DeploymentDetectionResult } from '../../shared/providerDetection.js'; import { pruneProviderGroupBindings } from '../provider-group/bindings.js'; import { writeProviderGroup } from '../provider-group/store.js'; +import { ModelCatalogOverrideLossError } from '../maker-host/model-catalog-override-store.js'; /** * provider:* IPC handlers。 * @@ -31,6 +32,7 @@ import { type AgentKind, type Catalog, type CustomProviderConfig, + type Effort, type ProviderModelDiscoveryFailure, type ProviderPreset, type ProviderView, @@ -52,6 +54,7 @@ import type { ModelPriceOverrideTarget, ModelPriceOverrideView, } from '../../shared/modelPriceOverride.js'; +import type { ModelCatalogThinkingView } from '../../shared/modelCatalogThinking.js'; import type { MoneyCurrency } from '../../shared/regionalMoney.js'; import { MAX_PROVIDER_ORDER_ID_LENGTH, @@ -483,9 +486,47 @@ export interface ProviderHandlerDeps { readModelContextLimit?(target: ModelPriceOverrideTarget): ModelContextLimitView; validateModelContextLimit?(targets: readonly ModelPriceOverrideTarget[], limit: number): Promise; writeModelContextLimit?(targets: readonly ModelPriceOverrideTarget[], limit: number | null): void | Promise; + /** + * 单模型图片输入能力的本地目录 override。value=null 删除 override(回到跟随目录); + * isCustomized 让 UI 区分「跟随目录」与「显式声明了一个等于目录的值」。 + * 可选 —— 未注入(单测最小桩)时对应 handler 报 INTERNAL 而不是静默成功。 + */ + /** + * 单模型思考档位的本地目录 override。tiers=null 删除 override(回到跟随目录); + * isCustomized 让 UI 区分「跟随目录」与「显式声明了一个等于目录的值」。 + * 可选 —— 未注入(单测最小桩)时对应 handler 报 INTERNAL 而不是静默成功。 + */ + readModelCatalogThinking?(target: ModelPriceOverrideTarget): ModelCatalogThinkingView; + /** + * 写入该行**全部引擎**的 id(桥接两端 id 不同:运行期消费 thinking 档位的 pi 侧与主展示 + * 引擎不同,只写一边会让声明对运行期完全无效)。单次调用内原子写所有 key。 + */ + writeModelCatalogThinking?( + targets: readonly ModelPriceOverrideTarget[], + tiers: readonly Effort[] | null, + defaultEffort?: Effort | null, + ): void | Promise; + readModelCatalogImageInput?(target: ModelPriceOverrideTarget): ModelCatalogImageInputView; + /** + * 写入该行**全部引擎**的 id(桥接两端 id 不同:运行期消费能力的 pi 侧与主展示引擎不同, + * 只写一边会让声明对运行期完全无效)。单次调用内原子写所有 key。 + */ + writeModelCatalogImageInput?( + targets: readonly ModelPriceOverrideTarget[], + value: boolean | null, + ): void | Promise; + /** 写入后把 override 文件重新注入 active-catalog(写盘不会自动生效)。 */ readModelCompaction?(target: ModelPriceOverrideTarget): { pct: number; isCustomized: boolean }; writeModelCompaction?(targets: readonly ModelPriceOverrideTarget[], pct: number | null): Promise; stageClearProviderModelCompaction?(providerId: string): () => boolean; + syncLocalCatalogOverrides?(): void; +} + +/** 图片输入能力的读回视图。 */ +export interface ModelCatalogImageInputView { + /** null = 没有本地声明(跟随目录)。 */ + value: boolean | null; + isCustomized: boolean; } /** 上下文上限的读回视图(与写入返回同形,UI 一次拿齐当前值与是否自定义)。 */ @@ -2026,6 +2067,138 @@ export function registerProviderHandlers( }, ); + const requireCatalogImageInputDeps = (): { + read: NonNullable; + write: NonNullable; + } => { + if (!deps.readModelCatalogImageInput || !deps.writeModelCatalogImageInput) { + throwIpcError('INTERNAL', 'model catalog image input override is not wired'); + } + return { + read: deps.readModelCatalogImageInput, + write: deps.writeModelCatalogImageInput, + }; + }; + const parseCatalogImageInputTarget = (input: unknown): ModelPriceOverrideTarget => + parsePriceTarget(input); + /** + * SET/GET 的行目标:主目标 + 同行的其它引擎 id(relatedTargets)。与上下文上限同一套校验: + * 必须同 provider、agent 不重复。写/读都覆盖全部 id —— 运行期读的是 pi 侧 id,主展示引擎 + * 的 id 与它不一定相同。 + */ + const parseCatalogImageInputTargets = (input: unknown): ModelPriceOverrideTarget[] => { + const target = parseCatalogImageInputTarget(input); + const related = (input as { relatedTargets?: unknown }).relatedTargets; + if (related === undefined) return [target]; + if (!Array.isArray(related) || related.length > 2) { + throwIpcError('INVALID_PARAMS', 'invalid related image input targets'); + } + const targets = [target, ...related.map(parsePriceTarget)]; + if ( + targets.some((candidate) => candidate.providerId !== target.providerId) || + new Set(targets.map((candidate) => candidate.agent)).size !== targets.length + ) { + throwIpcError( + 'INVALID_PARAMS', + 'image input targets must name distinct harnesses of one provider', + ); + } + return targets; + }; + /** 多目标读回:任一声明即视为已声明,取第一个非空值(写入总是一起写,正常情况下同步)。 */ + const readCatalogImageInputTargets = (targets: readonly ModelPriceOverrideTarget[]) => { + const { read } = requireCatalogImageInputDeps(); + const views = targets.map((target) => read(target)); + // 展示值优先取运行期真正消费该能力的引擎(Pi)那一侧:它才是“会不会真的收图”的答案。 + // 仅当各引擎声明不一致(手改文件/旧版单键写入的存量数据)时两者才会不同。 + const piIndex = targets.findIndex((target) => target.agent === 'pi'); + const ordered = piIndex >= 0 ? [views[piIndex]!, ...views.filter((_, i) => i !== piIndex)] : views; + const firstNonNull = ordered.find((view) => view.value !== null)?.value ?? null; + const head = views[0]!; + return { + value: firstNonNull, + isCustomized: views.some((view) => view.isCustomized), + // 任一引擎与首个引擎的 (value, isCustomized) 不同 = 分叉(含“一边声明、一边跟随目录”)。 + // UI 据此允许“重选当前项”把各引擎键写回一致。 + ...(views.some((view) => view.value !== head.value || view.isCustomized !== head.isCustomized) + ? { diverged: true } + : {}), + }; + }; + // value 只在 SET 上校验:GET 请求本来就没有这个字段。 + const parseCatalogImageInputValue = (input: unknown): boolean | null => { + const value = (input as { value?: unknown }).value; + if (value !== null && typeof value !== 'boolean') { + throwIpcError('INVALID_PARAMS', 'image input override value must be boolean or null'); + } + return value; + }; + + registry.handle(MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_GET, async (event, input: unknown) => { + assertTrustedProviderMutationSender(event); + const targets = parseCatalogImageInputTargets(input); + // 读不做目录成员校验:目录漂移后 UI 仍要能显示并清掉指向已下架 id 的陈旧 override。 + return readCatalogImageInputTargets(targets); + }); + + registry.handle( + MAKER_INVOKE.MODEL_CATALOG_IMAGE_INPUT_SET, + async (event, input: unknown) => { + assertTrustedProviderMutationSender(event); + const targets = parseCatalogImageInputTargets(input); + const target = targets[0]!; + const value = parseCatalogImageInputValue(input); + const { write } = requireCatalogImageInputDeps(); + // 网关模型的能力声明由服务端目录控制,不开放本机 override:按钮已 disable,但受信 + // renderer 能绕过按钮直接调 preload,所以在这里再拒一次(与价格 override 同一道门)。 + if (target.providerId === 'xd') { + throwIpcError('INVALID_PARAMS', 'Cindy AI Gateway model capabilities are server-controlled'); + } + // 组织托管供应商的能力由管理员下发:override 是本机最高优先级,不加这道门就能让运行期 + // 能力偏离企业配置。与价格 override 同一位置、同一错误码(UI 侧也一并禁用控件)。 + if (deps.isOrganizationManagedProviderId(target.providerId)) { + throwIpcError('PERMISSION_DENIED', 'Enterprise model capabilities are managed by your organization'); + } + const ownerAtIngress = captureProviderOwnerSession(); + return withProviderConfigMutation(target.providerId, () => + enqueuePriceMutation(async () => { + // 目录成员校验必须在队列内:它带着 await,放在队列外会拿旧账号目录校验、再把结果 + // 落到新账号的文件里(与价格/上下文上限两个姊妹 handler 同一顺序)。 + // value=null 是「恢复跟随目录」,不校验成员 —— 目录漂移后的陈旧 override 必须能清掉。 + if (value !== null) { + for (const candidate of targets) await requirePriceTargetModel(candidate); + } + assertProviderMutationOwner( + ownerAtIngress, + 'active account changed before persisting image input override', + ); + try { + await write(targets, value); + } catch (err) { + log.warn('model catalog image input override persist failed', { + providerId: target.providerId, + agent: target.agent, + modelId: target.modelId, + error: err instanceof Error ? err.message : String(err), + }); + // 手改文件里有本版本无法保留的条目:把指引带给用户(可执行),而不是「保存失败」。 + if (err instanceof ModelCatalogOverrideLossError) { + throwIpcError('PRECONDITION_FAILED', err.message); + } + throwIpcError('INTERNAL', 'failed to persist model catalog image input override'); + } + // 写盘不会自动生效:先重读 override 注入活动目录,再刷新目录并广播 + // PROVIDER_CHANGED(不是 pricing 通道 —— 能力变了要让 renderer 重拉 provider + // 视图,抽屉据此刷新;价格通道只推价格)。 + deps.syncLocalCatalogOverrides?.(); + await refreshCatalogAfterCommit(); + deps.broadcastChanged(); + return readCatalogImageInputTargets(targets); + }), + ); + }, + ); + // One mutation implementation for manual forms and confirmed secret-bearing imports. async function createProviderFromInput( event: unknown, @@ -2100,6 +2273,155 @@ export function registerProviderHandlers( } }); } + /** + * 多目标读回。展示值**只认运行期真正消费档位的那一侧(Pi)**:Pi 侧「跟随供应商」 + * 就显示跟随,不能回落到别的引擎的声明上 —— 那会把 Codex 的值说成 Pi 的实际状态。 + * 没有任何 Pi 侧目标时(该行没有 Pi 投影)才退回首个非空值。 + * + * 档位数组按**内容**比较而非引用:每次读盘都会造新数组,引用比较会把「两侧声明 + * 完全一致」永远误判成 diverged,进而让「重选当前项」触发无效落盘与整目录刷新。 + */ + const readCatalogThinkingTargets = (targets: readonly ModelPriceOverrideTarget[]) => { + const { read } = requireCatalogThinkingDeps(); + const views = targets.map((target) => read(target)); + const piIndex = targets.findIndex((target) => target.agent === 'pi'); + const sameTiers = (a: string[] | null, b: string[] | null): boolean => + a === b || (a !== null && b !== null && a.length === b.length && a.every((t, i) => t === b[i])); + // 默认档也参与分叉判定(undefined 与 null 同归「未设」):两侧集合相同但默认档不同时, + // 「重选当前项」同样要能放行,否则默认档的分叉永远修不掉。 + const sameDefault = (a: string | null | undefined, b: string | null | undefined): boolean => + (a ?? null) === (b ?? null); + const primary = piIndex >= 0 ? views[piIndex]! : views[0]!; + const value = primary.value ?? (piIndex >= 0 ? null : views.find((view) => view.value !== null)?.value ?? null); + return { + value, + // 默认档跟着展示值那一侧走:写入侧靠它避免猜「排序第一档」把厂商默认改掉。 + ...(value !== null ? { defaultEffort: primary.defaultEffort ?? null } : {}), + isCustomized: views.some((view) => view.isCustomized), + ...(views.some( + (view) => + !sameTiers(view.value, primary.value) || + !sameDefault(view.defaultEffort, primary.defaultEffort) || + view.isCustomized !== primary.isCustomized, + ) + ? { diverged: true } + : {}), + }; + }; + /** 目录认可的思考档位全集;IPC 边界用它挡住非法档位,避免写出必然被 sanitize 丢弃的条目。 */ + const VALID_THINKING_TIERS = new Set([ + 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra', + ]); + const requireCatalogThinkingDeps = (): { + read: NonNullable; + write: NonNullable; + } => { + if (!deps.readModelCatalogThinking || !deps.writeModelCatalogThinking) { + throwIpcError('INTERNAL', 'model catalog thinking override is not wired'); + } + return { + read: deps.readModelCatalogThinking, + write: deps.writeModelCatalogThinking, + }; + }; + /** + * 思考档位的合法档位集合与目录同源(Effort)。非法档位在 IPC 边界就拒 —— 写下去只会被 + * sanitize 判成整条无效,用户界面表现为「保存了但没生效」,真正原因不落在任何界面上。 + */ + const parseCatalogThinkingTiers = (input: unknown): Effort[] | null => { + const value = (input as { value?: unknown }).value; + if (value === null) return null; + // 空数组是三态里的「显式不支持思考」,不是非法输入:底层 sanitize 明确支持 + // `efforts: []` 配 `defaultEffort: null`(localCatalogOverrides 的 additionModelFor + // 同款分支)。把它一并拒掉会让抽屉的第三态永远存不下来,用户只看到保存失败。 + if (!Array.isArray(value)) { + throwIpcError('INVALID_PARAMS', 'thinking tiers must be an array or null'); + } + const tiers = value.map((tier) => { + if (typeof tier !== 'string' || !VALID_THINKING_TIERS.has(tier as Effort)) { + throwIpcError('INVALID_PARAMS', `invalid thinking tier '${String(tier)}'`); + } + return tier as Effort; + }); + if (new Set(tiers).size !== tiers.length) { + throwIpcError('INVALID_PARAMS', 'thinking tiers must not repeat'); + } + return tiers; + }; + const parseCatalogThinkingDefault = (input: unknown, tiers: Effort[] | null): Effort | null => { + const raw = (input as { defaultEffort?: unknown }).defaultEffort; + if (raw === undefined || raw === null) return null; + if (typeof raw !== 'string' || !VALID_THINKING_TIERS.has(raw as Effort)) { + throwIpcError('INVALID_PARAMS', `invalid default thinking tier '${String(raw)}'`); + } + if (tiers === null || !tiers.includes(raw as Effort)) { + throwIpcError('INVALID_PARAMS', 'default thinking tier must be one of the declared tiers'); + } + // unreachable:上面的 includes 已保证 tiers 非空时 defaultEffort 在集合内。 + return raw as Effort; + }; + + registry.handle(MAKER_INVOKE.MODEL_CATALOG_THINKING_GET, async (event, input: unknown) => { + assertTrustedProviderMutationSender(event); + const targets = parseCatalogImageInputTargets(input); + // 读不做目录成员校验:目录漂移后 UI 仍要能显示并清掉指向已下架 id 的陈旧 override。 + return readCatalogThinkingTargets(targets); + }); + + registry.handle( + MAKER_INVOKE.MODEL_CATALOG_THINKING_SET, + async (event, input: unknown) => { + assertTrustedProviderMutationSender(event); + const targets = parseCatalogImageInputTargets(input); + const target = targets[0]!; + const tiers = parseCatalogThinkingTiers(input); + const defaultEffort = parseCatalogThinkingDefault(input, tiers); + const { write } = requireCatalogThinkingDeps(); + // 网关模型的能力声明由服务端目录控制,不开放本机 override:按钮已 disable,但受信 + // renderer 能绕过按钮直接调 preload,所以在这里再拒一次(与图片输入 override 同一道门)。 + if (target.providerId === 'xd') { + throwIpcError('INVALID_PARAMS', 'Cindy AI Gateway model capabilities are server-controlled'); + } + // 组织托管供应商的能力由管理员下发:override 是本机最高优先级,不加这道门就能让运行期 + // 能力偏离企业配置。与价格 override 同一位置、同一错误码(UI 侧也一并禁用控件)。 + if (deps.isOrganizationManagedProviderId(target.providerId)) { + throwIpcError('PERMISSION_DENIED', 'Enterprise model capabilities are managed by your organization'); + } + const ownerAtIngress = captureProviderOwnerSession(); + return withProviderConfigMutation(target.providerId, () => + enqueuePriceMutation(async () => { + // 目录成员校验必须在队列内:它带着 await,放在队列外会拿旧账号目录校验、再把结果 + // 落到新账号的文件里(与价格/上下文上限/图片输入这几个姊妹 handler 同一顺序)。 + // value=null 是「恢复跟随目录」,不校验成员 —— 目录漂移后的陈旧 override 必须能清掉。 + if (tiers !== null) { + for (const candidate of targets) await requirePriceTargetModel(candidate); + } + assertProviderMutationOwner( + ownerAtIngress, + 'active account changed before persisting thinking override', + ); + try { + await write(targets, tiers, defaultEffort); + } catch (err) { + log.warn('model catalog thinking override persist failed', { + providerId: target.providerId, + modelId: target.modelId, + tiers, + error: err instanceof Error ? err.message : String(err), + }); + throwIpcError('INTERNAL', 'failed to persist model catalog thinking override'); + } + // 写盘不会自动生效:先重读 override 注入活动目录,再刷新目录并广播 + // PROVIDER_CHANGED(不是 pricing 通道 —— 档位变了要让 renderer 重拉 provider + // 视图,抽屉据此把「跟随供应商」的括注刷新成新值)。 + deps.syncLocalCatalogOverrides?.(); + await refreshCatalogAfterCommit(); + deps.broadcastChanged(); + }), + ); + }, + ); + registry.handle(MAKER_INVOKE.PROVIDER_CUSTOM_CREATE, createProviderFromInput); async function updateProviderFromInput( diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 4999bd08089..2eeb239e98d 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -1193,6 +1193,7 @@ import { getDesktopSelectableCatalog, refreshActiveCatalogFromSource, refreshCustomProvidersIntoCatalog, + syncLocalCatalogOverridesIntoActiveCatalog, } from '../maker-host/createDesktopProviderService.js'; import { deviceAvailableModels, @@ -1223,6 +1224,12 @@ import { writeModelContextLimitsWithRefresh, } from '../maker-host/model-context-limit-store.js'; import { refreshAnthropicModelsFromProbe } from '../maker-host/model-discovery/anthropic.js'; +import { + readModelCatalogImageInput, + readModelCatalogThinking, + setModelCatalogThinking, + setModelCatalogImageInput, +} from '../maker-host/model-catalog-override-store.js'; import { refreshOpenAiMediaModels } from '../maker-host/model-discovery/openai-media.js'; import { refreshXaiMediaModels } from '../maker-host/model-discovery/xai-media.js'; import { testProviderConnection } from '../maker-host/provider-diagnostics.js'; @@ -7681,6 +7688,38 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) target.modelId, ), }), + readModelCatalogThinking: (target) => + readModelCatalogThinking({ + providerId: target.providerId, + modelId: target.modelId, + agent: target.agent, + }), + writeModelCatalogThinking: async (targets, tiers, defaultEffort) => { + await setModelCatalogThinking( + targets.map((target) => ({ + providerId: target.providerId, + modelId: target.modelId, + })), + tiers, + defaultEffort, + ); + }, + readModelCatalogImageInput: (target) => + readModelCatalogImageInput({ + providerId: target.providerId, + modelId: target.modelId, + agent: target.agent, + }), + writeModelCatalogImageInput: async (targets, value) => { + await setModelCatalogImageInput( + targets.map((target) => ({ + providerId: target.providerId, + modelId: target.modelId, + })), + value, + ); + }, + syncLocalCatalogOverrides: () => syncLocalCatalogOverridesIntoActiveCatalog(), readModelCompaction: (target) => { const provider = getActiveCatalog().providers.find((entry) => entry.id === target.providerId); const identity = provider ? modelCompactionTarget(provider, target.agent, target.modelId) : target; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 096ebd7ed14..98aba3105ca 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -6858,6 +6858,34 @@ contextBridge.exposeInMainWorld('electronAPI', { owner: import('../shared/modelContextLimit').ModelContextLimitOwner, ): Promise => ipcRenderer.invoke('maker:model-context-limit:set', target, limit, owner), + /** + * 单模型思考档位的本地目录 override(设置 → 模型 → 高级设置)。 + * tiers=null 表示「跟随供应商」;写入后 main 会重注 active-catalog 并广播。 + */ + getModelCatalogThinking: ( + target: import('../shared/modelCatalogThinking').ModelCatalogThinkingTarget, + ): Promise => + ipcRenderer.invoke('maker:model-catalog-thinking:get', target), + setModelCatalogThinking: ( + target: import('../shared/modelCatalogThinking').ModelCatalogThinkingTarget, + tiers: string[] | null, + defaultTier?: string | null, + ): Promise => + ipcRenderer.invoke('maker:model-catalog-thinking:set', { + ...target, + value: tiers, + defaultEffort: defaultTier ?? null, + }), + + getModelCatalogImageInput: ( + target: import('../shared/modelCatalogImageInput').ModelCatalogImageInputTarget, + ): Promise => + ipcRenderer.invoke('maker:model-catalog-image-input:get', target), + setModelCatalogImageInput: ( + target: import('../shared/modelCatalogImageInput').ModelCatalogImageInputTarget, + value: boolean | null, + ): Promise => + ipcRenderer.invoke('maker:model-catalog-image-input:set', { ...target, value }), resetModelContextLimit: ( target: import('../shared/modelContextLimit').ModelContextLimitTarget, owner: import('../shared/modelContextLimit').ModelContextLimitOwner, diff --git a/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx b/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx index 2aff87c8614..8db6b4554cd 100644 --- a/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx @@ -62,7 +62,10 @@ import { CLAUDE_GATEWAY_OPUS_PLAN_MISMATCH_REASON, CLAUDE_SUBSCRIPTION_OPUS_PLAN_MISMATCH_REASON, } from '../../../shared/claudeGatewayError'; -import { isPiImageInputUnsupportedError } from '../../../shared/inputError'; +import { + isPiImageCapabilityRefreshFailedError, + isPiImageInputUnsupportedError, +} from '../../../shared/inputError'; import type { ToolLoopErrorDetails } from '@cindy/maker-core'; import type { ErrorDiagnosisSource } from '@/lib/errorDiagnosisPrompt'; @@ -467,6 +470,9 @@ export function ErrorBanner({ displayError = t('chat.errorBanner.codexResumeNotReady'); } else if (isPiImageInputUnsupportedError(error)) { displayError = t('ipcError.PI_IMAGE_INPUT_UNSUPPORTED'); + } else if (isPiImageCapabilityRefreshFailedError(error)) { + // 能力声明已写入目录、但子进程还没加载(回合在跑或重载被拒):可重试,不是模型不支持。 + displayError = t('ipcError.PI_IMAGE_CAPABILITY_REFRESH_FAILED'); } else if (isCredentialSwitchBusy) { displayError = t('chat.errorBanner.credentialSwitchBusy'); } else if (isAccountBoundaryPending) { diff --git a/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx b/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx index 68ac7664a2b..fc0b507448d 100644 --- a/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx +++ b/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx @@ -28,7 +28,16 @@ import { useTranslation } from 'react-i18next'; import { SegmentedControl } from '@/components/ui/segmented-control'; import { cn } from '@/lib/utils'; import { providerViewToCustomProviderConfig, updateCustomProvider } from '@/lib/customProviders'; -import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from '@/components/ui/dropdown-menu'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, +} from '@/components/ui/dropdown-menu'; import { MODEL_HARNESS_COLOR } from '@/lib/modelHarnessPresentation'; import { ModelCompatibilityNotice } from '@/components/new-chat/ModelCompatibilityNotice'; import { MODEL_PROTOCOL_LABEL, MODEL_PROTOCOL_OPTIONS } from '@/lib/modelProtocolLabel'; @@ -39,6 +48,8 @@ import { ClaudeMark } from '@/components/icons/ClaudeMark'; import { CodexMark } from '@/components/icons/CodexMark'; import { PiMark } from '@/components/icons/PiMark'; import { useModelContextLimit } from '@/hooks/useModelContextLimit'; +import { useModelCatalogImageInput } from '@/hooks/useModelCatalogImageInput'; +import { useModelCatalogThinking } from '@/hooks/useModelCatalogThinking'; import { ModelCompactionSettings } from './ModelCompactionSettings'; import { modelPriceDetailRows, type ModelPricePresentation } from '@/lib/modelPriceFormat'; import { @@ -284,6 +295,102 @@ export function ModelAdvancedDrawer({ [contextAgent, contextModel, provider.id, row, chatAgents], ); const ctx = useModelContextLimit(open ? contextTarget : null); + // 图片输入能力的本地声明。与上下文上限同一个目标形状;agent 只用于目录成员校验。 + // 必须覆盖该行全部引擎的 id:运行期真正消费这个能力的只有 Pi,而桥接投影两端 id 不同 + // (openai 行:codex 用 gpt-5.6-sol、pi 用 chatgpt/gpt-5.6-sol)—— 只写主展示引擎的 id, + // Pi 侧读不到(override 按 providerId:modelId 精确匹配),UI 却会显示「已声明」。 + const imageInputTarget = useMemo(() => { + if (!open || !primaryAgent || !primaryModel) return null; + const relatedTargets = (row?.avail ?? []) + .filter((agent) => agent !== primaryAgent) + .flatMap((agent) => { + const model = row?.byAgent[agent]; + return model ? [{ providerId: provider.id, agent, modelId: model.id }] : []; + }); + return { + providerId: provider.id, + agent: primaryAgent, + modelId: primaryModel.id, + ...(relatedTargets.length > 0 ? { relatedTargets } : {}), + }; + }, [open, primaryAgent, primaryModel, provider.id, row]); + const imageInput = useModelCatalogImageInput(imageInputTarget); + // 思考档位的本地声明。目标形状与图片输入完全一致:必须覆盖该行全部引擎的 id —— + // 运行期真正消费 thinking 档位的是 Pi,桥接投影两端 id 不同,只写主展示引擎会让声明 + // 对运行期无效、UI 却显示「已声明」。 + const thinkingTarget = useMemo(() => { + if (!open || !primaryAgent || !primaryModel) return null; + const relatedTargets = (row?.avail ?? []) + .filter((agent) => agent !== primaryAgent) + .flatMap((agent) => { + const model = row?.byAgent[agent]; + return model ? [{ providerId: provider.id, agent, modelId: model.id }] : []; + }); + return { + providerId: provider.id, + agent: primaryAgent, + modelId: primaryModel.id, + ...(relatedTargets.length > 0 ? { relatedTargets } : {}), + }; + }, [open, primaryAgent, primaryModel, provider.id, row]); + const thinking = useModelCatalogThinking(thinkingTarget); + /** + * 档位组合用固定候选(与目录 Effort 全集同序)而不是自由输入:组合爆炸(2^7)无法 + * 列举,而真实诉求只有「沿用厂商 / 声明常见的几档」两类。勾选语义 = 该模型支持这一档。 + */ + // 与目录 Effort 全集同序(含 ultra:它是合法 Effort,IPC 与 Pi 映射都接受)。 + // 漏掉它会让「已有含 ultra 的声明」在用户勾选其它档位时被按这份列表重建 → 静默删除。 + const THINKING_TIER_CHOICES = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const; + const setThinkingTiers = async (tiers: string[] | null) => { + // 已经是该状态就别再写一次:免掉一次空写入引起的全量目录刷新与广播。各引擎键分叉时 + // 必须放行 —— 否则「重选当前项」被 no-op 挡掉,分叉永远修不掉。 + const already = + tiers === null + ? !thinking.isCustomized + : thinking.isCustomized && + tiers.length === (thinking.value?.length ?? -1) && + tiers.every((tier) => thinking.value?.includes(tier)); + if (already && !thinking.diverged) return; + // 默认档**不猜**:沿用读回回来的既有默认档(仍在新集合内才继续沿用),用户没有 + // 单独改默认档的入口,猜「排序第一档」会把厂商声明的默认(如 max)静默改成 low。 + const keptDefault = + tiers !== null && thinking.defaultEffort && tiers.includes(thinking.defaultEffort) + ? thinking.defaultEffort + : null; + const persisted = await thinking.setTiers(tiers, keptDefault); + if (!persisted) { + const reason = thinking.errorReason; + toast.error( + reason + ? t('settings.providers.models.advanced.thinkingOverride.saveFailedWithReason', { reason }) + : t('settings.providers.models.advanced.thinkingOverride.saveFailed'), + ); + } + }; + // 'inherit' = 删除本机 override,回到跟随供应商。失败由 hook 回读真值,这里只负责提示。 + const setImageInput = async (next: string) => { + const value = next === 'inherit' ? null : next === 'true'; + // 已经是该状态就别再写一次:免掉一次空写入引起的全量目录刷新与广播。但各引擎键分叉时 + // 必须放行 —— 否则“重选当前项”被 no-op 挡掉,分叉永远修不掉(UI 显示一侧、运行期读另一侧)。 + const already = + value === null ? !imageInput.isCustomized : imageInput.isCustomized && value === imageInput.value; + if (already && !imageInput.diverged) { + return; + } + const persisted = await imageInput.setValue(value); + if (!persisted) { + // main 在这条路径上会给出可执行指引(手改文件里有无法保留的条目时要先修文件): + // 把它带进提示,否则用户反复保存失败却看不到唯一的修复方式。 + const reason = imageInput.errorReason; + toast.error( + reason + ? t('settings.providers.models.advanced.imageInputOverride.saveFailedWithReason', { + message: reason, + }) + : t('settings.providers.models.advanced.imageInputOverride.saveFailed'), + ); + } + }; const canEditProtocol = provider.source === 'user' && !provider.auth?.native && !isManagedSidecarProviderId(provider.id); const imageAgents = canEditProtocol ? provider.agents.filter(agent => provider.routing?.[agent]?.imageApi === 'openai-images') @@ -354,6 +461,14 @@ export function ModelAdvancedDrawer({ const [ctxDraft, setCtxDraft] = useState(''); + // 提交后写入是异步的:hook 先置 loading、等 IPC 回声才落新值。若提交瞬间就丢掉草稿, + // 说明行会先退回**旧的** effectiveLimit、回声到了再跳新值 —— 用户实测到的「闪一下旧值」。 + // 这里把「提交在途」也当成还在编辑:显示值继续取草稿,直到写入收口。 + const [ctxCommitting, setCtxCommitting] = useState(false); + // 提交代次:目标(模型/agent/provider)一变或又一次提交开始,旧提交的 finally 就不许再 + // 清掉新的 committing 状态 —— 否则 A 写未完成时切到 B 并提交,A 完成会把 B 的「提交在途」 + // 提前收掉,B 在写入期间重新显示旧的 effectiveLimit(本次修改要消除的闪回原地复活)。 + const ctxCommitGen = useRef(0); const ctxDirtyRef = useRef(false); const defaultWindow = contextAgent === 'codex' && ctx.codexContext ? ctx.codexContext.contextWindow : contextModel?.contextWindow ?? 0; @@ -365,9 +480,28 @@ export function ModelAdvancedDrawer({ const minimumContextK = isLocalRuntimeBetaProviderId(provider.id) ? 1 : Math.max(1, Math.floor(Math.min(100_000, ...modelWindows) / 1000)); const routeWindow = supportsMillionContext ? 1_000_000 : primaryModel?.contextWindowMax ?? primaryModel?.contextWindow ?? 0; + // 「上游最大上下文」这一行只印上游**确实下发过**的窗口,来源按优先级: + // 1) contextWindowMax —— 路由/网关声明的容量; + // 2) contextWindowVerified === true 的 contextWindow —— 用户或预设显式配置的窗口; + // 3) 都没有 → 未声明。 + // 第 3 条必须排除「自定义模型缺元数据时的 200K 展示兜底」:那个常量在 + // user-provider.ts 里明确写着「仅用于展示」、不带 contextWindowVerified —— 把它印成 + // 上游下发的窗口,会让用户以为容量只有 200K,而运行期窗口取模型级上下文上限 + // (实测报障:圆环 1.0M、这里 200K)。 + // 与 routeWindow 的区别:routeWindow 是路由展示用的乐观值(供百万上下文档位等 + // 比较),declaredWindow 是这一行的**如实声明**口径。 + const declaredCapacity = primaryModel?.contextWindowMax ?? 0; + const declaredWindow = + declaredCapacity > 0 + ? declaredCapacity + : primaryModel?.contextWindowVerified === true + ? (primaryModel.contextWindow ?? 0) + : 0; const effectiveLimit = ctx.limit ?? (defaultWindow > 0 ? defaultWindow : null); useEffect(() => { ctxDirtyRef.current = false; + ctxCommitGen.current += 1; + setCtxCommitting(false); setCtxDraft(''); setPriceDialogOpen(false); }, [open, primaryAgent, primaryModel?.id, provider.id]); @@ -383,11 +517,20 @@ export function ModelAdvancedDrawer({ const commitCtxDraft = useCallback(() => { if (!ctxDirtyRef.current || ctxInvalid || ctx.loading || localConfigurationBlocked) return; ctxDirtyRef.current = false; - void ctx.setLimit(ctxDraft.trim() === '' ? null : parsedTokens); + // 提交在途期间锁住 displayedLimit:hook 的 promise 在它把新值(或失败回滚后的值) + // 写进 state 之后才 resolve,不锁会闪回一帧旧值。仅当没有更新的提交/重置发生时 + // 才清状态(ctxCommitGen 递增即代表有更新的那次)。 + const generation = (ctxCommitGen.current += 1); + setCtxCommitting(true); + void Promise.resolve(ctx.setLimit(ctxDraft.trim() === '' ? null : parsedTokens)).finally(() => { + if (ctxCommitGen.current === generation) setCtxCommitting(false); + }); }, [ctx, ctxDraft, ctxInvalid, parsedTokens, localConfigurationBlocked]); const resetCtx = useCallback(() => { if (localConfigurationBlocked) return; ctxDirtyRef.current = false; + ctxCommitGen.current += 1; + setCtxCommitting(false); setCtxDraft(defaultWindow > 0 ? editableContextK(defaultWindow) : ''); void ctx.reset(); }, [ctx, defaultWindow, localConfigurationBlocked]); @@ -401,9 +544,11 @@ export function ModelAdvancedDrawer({ } const capabilityModel = primaryModel.mode === 'video_generation' ? videoModel ?? primaryModel : imageModel ?? videoModel ?? primaryModel; - const vision = imageModel?.modalities - ? imageModel.modalities.input.includes('image') ? 'vision' : 'no-vision' - : primaryModel.supportsImageInput !== undefined + // 本行的能力事实必须属于**被编辑的那个模型**:图片输入控件把声明写到该行选中模型的 + // override,图标/文案却显示图片/视频生成模型(上游 capabilityModel)会变成「编辑 A 却 + // 显示 B」。生成模型的能力展示由上游新增的图片/视频能力行与 modalities 行承担。 + const vision = + primaryModel.supportsImageInput !== undefined ? primaryModel.supportsImageInput ? 'vision' : 'no-vision' @@ -450,12 +595,22 @@ export function ModelAdvancedDrawer({ const protocols = modelProtocolComparison(provider, row.byAgent); const protocolLabel = (api: PiModelApi | null) => api ? MODEL_PROTOCOL_LABEL[api] ?? null : t('settings.providers.models.advanced.undeclared'); - const displayedLimit = ctxDirtyRef.current + const displayedLimit = ctxDirtyRef.current || ctxCommitting ? ctxDraft.trim() === '' ? defaultWindow : parsedTokens : effectiveLimit; - const editRouteWindow = contextModel?.contextWindowMax ?? defaultWindow; + // 只有「上游声明的容量」比它小时才该告警;未声明容量时拿工作默认值当窗口会误报 + // (自定义连接上 200K 只是兜底,不是任何人的声明)。来源与上方「上游最大上下文」同一套: + // contextWindowMax → 已验证的 contextWindow → 未声明,否则「已验证窗口但无 max」的模型 + // 把限制填到窗口以上也不会告警。 + const editCapacity = contextModel?.contextWindowMax ?? 0; + const editRouteWindow = + editCapacity > 0 + ? editCapacity + : contextModel?.contextWindowVerified === true + ? (contextModel.contextWindow ?? 0) + : 0; const overRouteWindow = editRouteWindow > 0 && displayedLimit !== null && @@ -877,18 +1032,18 @@ export function ModelAdvancedDrawer({
- {routeWindow > 0 ? ( + {declaredWindow > 0 ? ( <> - {formatExactTokens(routeWindow, locale)} + {formatExactTokens(declaredWindow, locale)} {t('settings.providers.models.advanced.tokensApprox', { - approx: approxTokens(routeWindow), + approx: approxTokens(declaredWindow), })} ) : ( - {t('settings.providers.models.advanced.catalogMissing')} + {t('settings.providers.models.advanced.undeclared')} )} @@ -955,20 +1110,150 @@ export function ModelAdvancedDrawer({ )} - - - + {/* + 三态声明:目录未声明图片能力时,Pi 会在客户端就拒收图片(请求根本不出网), + 用户此前没有任何可点的入口去声明它 —— 报错文案让人去开的开关并不存在。 + 这里把状态与声明合成一个控件:图标+文案保留「目录怎么说」的事实, + 下拉写本机目录 override(不改连接配置,preset 连接因此仍跟随官方目录)。 + */} + + + + + + void setImageInput(next)} + > + + {t('settings.providers.models.advanced.imageInputOverride.inherit')} + + + {t('settings.providers.models.advanced.imageInputOverride.declaredTrue')} + + + {t('settings.providers.models.advanced.imageInputOverride.declaredFalse')} + + + + + + + {/* + 思考档位三态声明:目录说「不支持思考」时,Pi 客户端就没有 thinking + 通道,模型推理只能随 content 返回、被当成普通正文渲染(用户看到的是 + 「思考被写进正文」)。此前没有任何入口能声明这件事。勾选语义 = 该模型 + 支持这一档;「跟随供应商」删除本机 override。 + */} + + + + + + void setThinkingTiers(null)}> + {t('settings.providers.models.advanced.thinkingOverride.inherit')} + + + {THINKING_TIER_CHOICES.map((tier) => { + const active = thinking.value?.includes(tier) ?? false; + return ( + { + // 多选:preventDefault 关掉 Radix 的自动关闭,用户可以连续勾多档。 + event.preventDefault(); + const next = active + ? (thinking.value ?? []).filter((item) => item !== tier) + : THINKING_TIER_CHOICES.filter((item) => + item === tier || (thinking.value ?? []).includes(item), + ); + // 空组合 = 「明确不支持思考」,必须走 null 之外的显式空数组: + // null 是「跟随供应商」,两者语义不同。 + void setThinkingTiers(next.length > 0 ? next : []); + }} + > + {/* 档位名一律用既有正本 effortLevels:与「默认推理强度」 + 选择器逐字一致,避免同一档位在两处叫不同名字(也避免 + ultra 被译成与 xhigh 同名的「超高」)。 */} + {t(`effortLevels.${tier}`)} + + ); + })} + + {capabilityModel.modalities && (['input', 'output'] as const).map((direction) => ( diff --git a/apps/desktop/src/renderer/components/settings/__tests__/ModelAdvancedDrawer.test.tsx b/apps/desktop/src/renderer/components/settings/__tests__/ModelAdvancedDrawer.test.tsx index a42b01f7411..3a15eb16f34 100644 --- a/apps/desktop/src/renderer/components/settings/__tests__/ModelAdvancedDrawer.test.tsx +++ b/apps/desktop/src/renderer/components/settings/__tests__/ModelAdvancedDrawer.test.tsx @@ -30,6 +30,34 @@ vi.mock('@/hooks/useModelContextLimit', () => ({ }; }, })); +const imageInputMocks = vi.hoisted(() => ({ + setValue: vi.fn(async () => true), + target: vi.fn(), + value: null as boolean | null, + isCustomized: false, + diverged: false, + errorReason: undefined as string | undefined, + saving: false, + loading: false, +})); +vi.mock('@/hooks/useModelCatalogImageInput', () => ({ + useModelCatalogImageInput: (target: unknown) => { + imageInputMocks.target(target); + return { + value: imageInputMocks.value, + isCustomized: imageInputMocks.isCustomized, + diverged: imageInputMocks.diverged, + errorReason: imageInputMocks.errorReason, + loading: imageInputMocks.loading, + saving: imageInputMocks.saving, + error: false, + setValue: imageInputMocks.setValue, + }; + }, +})); +vi.mock('@/lib/toast', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); vi.mock('@/state/modelVisibilityPrefs', () => ({ useModelVisibilityVersion: () => 0, isModelEnabled: (_agent: unknown, _provider: unknown, model: { defaultEnabled?: boolean }) => model.defaultEnabled !== false, @@ -97,6 +125,12 @@ afterEach(cleanup); beforeEach(() => { vi.clearAllMocks(); mocks.limit = null; + imageInputMocks.value = null; + imageInputMocks.isCustomized = false; + imageInputMocks.diverged = false; + imageInputMocks.errorReason = undefined; + imageInputMocks.saving = false; + imageInputMocks.loading = false; vi.mocked(getProviderModelEffort).mockReset(); }); @@ -274,6 +308,94 @@ describe('model advanced editor', () => { expect(screen.getByText(/Chat Completions/)).toBeTruthy(); }); + it('keeps the thinking declaration disabled until the first read lands', async () => { + // 首次读回前 value 是空占位:此时能点开菜单并勾一档,就会把「只有这一档」当成完整 + // 集合写盘,覆盖本机已声明的档位与默认档。 + let release!: (view: unknown) => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const setTiers = vi.fn(async () => undefined); + const previous = window.electronAPI; + Object.defineProperty(window, 'electronAPI', { configurable: true, value: { maker: { + updateCustomProvider: vi.fn(async () => ({ ok: true })), + getModelCatalogThinking: vi.fn(() => pending), + setModelCatalogThinking: setTiers, + } } }); + const source = { ...buildUserProvider({ id: 'fixture', name: 'Fixture', runtimes: { + codex: { baseUrl: 'https://supplier.example/v1', wireProtocol: 'openai-responses', requestPath: '/custom/chat', + models: [{ id: 'gpt-6', name: 'GPT-6', efforts: [] as never, route: { baseUrl: 'https://supplier.example/v1', wireProtocol: 'openai-responses', requestPath: '/custom/chat' } }] }, + } }), connected: true } as ProviderView; + try { + render(drawer(source.models.codex![0], 'high', ['high'], source)); + const trigger = screen.getByRole('button', { name: 'settings.providers.models.advanced.thinkingOverride.label' }); + expect((trigger as HTMLButtonElement).disabled).toBe(true); + release({ value: ['low', 'high', 'max'], defaultEffort: 'max', isCustomized: true }); + await waitFor(() => expect((trigger as HTMLButtonElement).disabled).toBe(false)); + expect(setTiers).not.toHaveBeenCalled(); + } finally { Object.defineProperty(window, 'electronAPI', { configurable: true, value: previous }); } + }); + + it('disables the thinking declaration for organization-managed providers', () => { + // 企业托管:能力由管理员下发,本机 override 是最高优先级,必须在控件上就拦住。 + // (按钮禁用挡不住受信 renderer 直调,IPC 侧另有 PERMISSION_DENIED 兜底。) + const previous = window.electronAPI; + Object.defineProperty(window, 'electronAPI', { configurable: true, value: { maker: { + updateCustomProvider: vi.fn(async () => ({ ok: true })), + getModelCatalogThinking: vi.fn(async () => ({ value: null, isCustomized: false })), + setModelCatalogThinking: vi.fn(async () => undefined), + } } }); + // 企业托管的判定口径是 source === 'organization'(isOrganizationManagedProvider)。 + const managed = { ...buildUserProvider({ id: 'corp', name: 'Corp', runtimes: { + codex: { baseUrl: 'https://corp.example/v1', wireProtocol: 'openai-responses', requestPath: '/custom/chat', + models: [{ id: 'corp-model', name: 'Corp Model', route: { baseUrl: 'https://corp.example/v1', wireProtocol: 'openai-responses', requestPath: '/custom/chat' } }] }, + } }), connected: true, source: 'organization' } as ProviderView; + try { + render(drawer(managed.models.codex![0], 'high', ['high'], managed)); + const trigger = screen.getByRole('button', { name: 'settings.providers.models.advanced.thinkingOverride.label' }); + expect((trigger as HTMLButtonElement).disabled).toBe(true); + } finally { Object.defineProperty(window, 'electronAPI', { configurable: true, value: previous }); } + }); + + it('declares thinking tiers as a local catalog override instead of editing the connection', async () => { + // 目录把该模型声明成零档位(不支持思考),用户从高级设置里补上真实档位。 + const setTiers = vi.fn(async () => undefined); + const getView = vi.fn(async () => ({ value: null, isCustomized: false })); + const previous = window.electronAPI; + Object.defineProperty(window, 'electronAPI', { configurable: true, value: { maker: { + updateCustomProvider: vi.fn(async () => ({ ok: true })), + getModelCatalogThinking: getView, + setModelCatalogThinking: setTiers, + } } }); + const source = { ...buildUserProvider({ id: 'fixture', name: 'Fixture', runtimes: { + codex: { baseUrl: 'https://supplier.example/v1', wireProtocol: 'openai-responses', requestPath: '/custom/chat', + models: [{ id: 'gpt-6', name: 'GPT-6', efforts: [] as never, route: { baseUrl: 'https://supplier.example/v1', wireProtocol: 'openai-responses', requestPath: '/custom/chat' } }] }, + } }), connected: true } as ProviderView; + try { + render(drawer(source.models.codex![0], 'high', ['high'], source)); + fireEvent.keyDown( + screen.getByRole('button', { name: 'settings.providers.models.advanced.thinkingOverride.label' }), + { key: 'ArrowDown' }, + ); + // 档位名走既有正本 effortLevels.*:与「默认推理强度」选择器逐字一致。 + const high = await screen.findByRole('menuitemcheckbox', { name: 'effortLevels.high' }); + fireEvent.click(high); + await waitFor(() => expect(setTiers).toHaveBeenCalledTimes(1)); + // 写入的是目录 override(不是连接配置),且带上目标行全部引擎的 id。 + const [target, tiers, defaultTier] = setTiers.mock.calls[0]! as unknown as [ + { providerId: string; modelId: string; relatedTargets?: unknown[] }, string[] | null, string | null, + ]; + expect(target.providerId).toBe('fixture'); + expect(target.modelId).toBe('gpt-6'); + expect(tiers).toEqual(['high']); + // 首次声明没有既有默认档可沿用 → 留 null 让运行期按目录默认回退, + // 而不是猜「排序第一档」(那会把厂商默认如 max 静默改成 high)。 + expect(defaultTier).toBeNull(); + // 连接配置不得被这条路径改写。 + expect(window.electronAPI.maker.updateCustomProvider).not.toHaveBeenCalled(); + } finally { Object.defineProperty(window, 'electronAPI', { configurable: true, value: previous }); } + }); + it('saves protocol selection to the selected engine and model without writing derived model limits', async () => { const update = vi.fn(async (...args: unknown[]) => { void args; @@ -370,6 +492,33 @@ describe('model advanced editor', () => { expect(mocks.setLimit).toHaveBeenCalledTimes(1); }); + it('keeps the trailing token readout on the typed value until the write lands', async () => { + // 提交后写入是异步的(hook 先 loading、仍持有旧 limit)。修复前抽屉在 blur 瞬间丢掉草稿, + // 说明行会先退回旧的 400,000、等回声到了再跳 500,000 —— 用户实测到的「闪一下旧值」。 + mocks.limit = 400_000; + let release: () => void = () => {}; + mocks.setLimit.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + draw(); + const input = screen.getByRole('textbox') as HTMLInputElement; + expect(input.value).toBe('400'); + fireEvent.change(input, { target: { value: '500' } }); + fireEvent.blur(input); + expect(mocks.setLimit).toHaveBeenLastCalledWith(500_000); + // 写入在途:说明行必须继续显示刚输入的值。 + expect(screen.getByText(/advanced\.contextLimitRoute 500,000/)).toBeTruthy(); + expect(screen.queryByText(/advanced\.contextLimitRoute 400,000/)).toBeNull(); + // 回声落地(hook 此时已持有新值)后再收口:仍然是新值,不回退。 + mocks.limit = 500_000; + release(); + await waitFor(() => expect(screen.getByText(/advanced\.contextLimitRoute 500,000/)).toBeTruthy()); + expect(screen.queryByText(/advanced\.contextLimitRoute 400,000/)).toBeNull(); + }); + it.each(['cindy-local-ollama', 'ollama', 'cindy-local-lmstudio'])('keeps %s editable at 1K even when the catalog advertises a large window', (id) => { render(drawer(model, model.defaultEffort, model.efforts, { ...provider, id, source: 'user' })); const input = screen.getByRole('textbox'); @@ -394,6 +543,243 @@ describe('model advanced editor', () => { }); + // 上游未声明容量时不能把「工作默认值」印成「上游最大上下文」:自定义连接的 200K 是兜底, + // 而运行期窗口其实取模型级上下文上限(用户实测报障:圆环 1.0M / 这里 200K)。 + function withoutCapacity(over: Partial = {}): CatalogModel { + const { contextWindowMax: _omit, ...rest } = model; + return { ...rest, contextWindow: 200_000, ...over }; + } + + it('shows "not declared" instead of the working default when the provider declares no capacity', () => { + draw(withoutCapacity()); + // 这一行只报「容量未声明」:不再把工作默认值(200K 兜底,或目录给的 1M)当成容量塞进这一格。 + expect( + screen.getAllByText('settings.providers.models.advanced.undeclared').length, + ).toBeGreaterThan(0); + expect(screen.queryByText('200,000')).toBeNull(); + expect(screen.queryByText('settings.providers.models.advanced.tokensApprox')).toBeNull(); + // 工作窗口仍然可见 —— 在「上下文上限」那行的说明里(未设上限时取目录默认值)。 + // 那行带插值(t 的 mock 会拼上 tokens),所以用正则而非精确匹配。 + expect(screen.getByText(/advanced\.contextLimitRoute/)).toBeTruthy(); + }); + + it('shows the window upstream handed down even when no capacity is declared', () => { + // opencode-go 的 deepseek-v4.1-flash 就是这种:没有 contextWindowMax,但预设/服务端目录 + // 下发了窗口(buildUserProvider 会带上 contextWindowVerified)→ 这行要显示该窗口,而不是「未声明」。 + draw({ ...withoutCapacity(), contextWindow: 1_048_576, contextWindowVerified: true }); + expect(screen.getByText('1,048,576')).toBeTruthy(); + // 「约 X」只由规格那一行渲染(「未声明」这个 key 在抽屉里另有多处,不能拿它判负)。 + expect(screen.getByText('settings.providers.models.advanced.tokensApprox')).toBeTruthy(); + }); + + it('lets the user declare an undeclared image capability from the drawer', async () => { + // 上游未声明图片能力时 Pi 会在客户端拒收图片,而此前没有任何可点的声明入口。 + draw(); + const trigger = screen.getByRole('button', { + name: 'settings.providers.models.advanced.imageInputOverride.label', + }); + // 未声明时显示「跟随目录」。 + expect(trigger.textContent).toContain('settings.providers.models.advanced.imageInputOverride.inherit'); + + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + fireEvent.click( + await screen.findByRole('menuitemradio', { + name: 'settings.providers.models.advanced.imageInputOverride.declaredTrue', + }), + ); + await waitFor(() => expect(imageInputMocks.setValue).toHaveBeenCalledWith(true)); + }); + + it('shows a declared capability and can return to following the catalog', async () => { + imageInputMocks.value = false; + imageInputMocks.isCustomized = true; + draw(); + const trigger = screen.getByRole('button', { + name: 'settings.providers.models.advanced.imageInputOverride.label', + }); + expect(trigger.textContent).toContain('settings.providers.models.advanced.imageInputOverride.declaredFalse'); + + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + fireEvent.click( + await screen.findByRole('menuitemradio', { + name: 'settings.providers.models.advanced.imageInputOverride.inherit', + }), + ); + await waitFor(() => expect(imageInputMocks.setValue).toHaveBeenCalledWith(null)); + }); + + it('allows re-selecting the current value to repair a split across engine keys', async () => { + // 各引擎键分叉(手工改文件/旧版单键写入的存量数据)时,同值 no-op 会把“重选当前项” + // 挡掉 —— 分叉永远修不掉,UI 显示一侧而运行期读另一侧。diverged 时必须放行写入。 + imageInputMocks.value = true; + imageInputMocks.isCustomized = true; + imageInputMocks.diverged = true; + draw(); + const trigger = screen.getByRole('button', { + name: 'settings.providers.models.advanced.imageInputOverride.label', + }); + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + fireEvent.click( + await screen.findByRole('menuitemradio', { + name: 'settings.providers.models.advanced.imageInputOverride.declaredTrue', + }), + ); + await waitFor(() => expect(imageInputMocks.setValue).toHaveBeenCalledWith(true)); + }); + + it('skips the write when the user picks the state it is already in', async () => { + imageInputMocks.value = true; + imageInputMocks.isCustomized = true; + draw(); + const trigger = screen.getByRole('button', { + name: 'settings.providers.models.advanced.imageInputOverride.label', + }); + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + fireEvent.click( + await screen.findByRole('menuitemradio', { + name: 'settings.providers.models.advanced.imageInputOverride.declaredTrue', + }), + ); + expect(imageInputMocks.setValue).not.toHaveBeenCalled(); + }); + + it('keeps the control usable and visually stable while a provider refresh refetches', () => { + // 写入后 main 广播 PROVIDER_CHANGED,hook 会重读;重读期间值原样保留, + // 既不禁用也不改透明度 —— 否则一次切换会出现两次明暗跳变(用户报障:闪一下)。 + imageInputMocks.loading = true; + draw(); + const trigger = screen.getByRole('button', { + name: 'settings.providers.models.advanced.imageInputOverride.label', + }); + expect(trigger).toHaveProperty('disabled', false); + expect(trigger.className).not.toContain('disabled:opacity'); + expect(trigger.className).not.toContain('opacity-60'); + }); + + it('reports a failed declaration instead of silently keeping the optimistic value', async () => { + const { toast } = await import('@/lib/toast'); + imageInputMocks.setValue.mockResolvedValueOnce(false); + draw(); + const trigger = screen.getByRole('button', { + name: 'settings.providers.models.advanced.imageInputOverride.label', + }); + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + fireEvent.click( + await screen.findByRole('menuitemradio', { + name: 'settings.providers.models.advanced.imageInputOverride.declaredTrue', + }), + ); + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + 'settings.providers.models.advanced.imageInputOverride.saveFailed', + ), + ); + }); + + it('disables the declaration control for organization-managed providers', () => { + // 组织托管供应商的能力由管理员下发:本机不可覆盖。控件保留但禁用(与 xd 网关同一约定), + // Main IPC 侧另有 PERMISSION_DENIED 拒写兜底 —— 按钮禁用挡不住受信 renderer 直调 preload。 + const orgProvider = { ...provider, id: 'corp-gateway', source: 'organization' } as ProviderView; + render(drawer(model, model.defaultEffort, model.efforts, orgProvider)); + expect( + screen.getByRole('button', { + name: 'settings.providers.models.advanced.imageInputOverride.label', + }), + ).toHaveProperty('disabled', true); + }); + + it('shows the actionable reason from main when a hand-edited file blocks the write', async () => { + // store 拒绝写入时给出「先修正 model-catalog-overrides.json」的指引;只显示通用 + // 「保存失败」会让用户反复失败却看不到唯一的修复方式。 + const { toast } = await import('@/lib/toast'); + imageInputMocks.setValue.mockResolvedValueOnce(false); + imageInputMocks.errorReason = '请先修正 model-catalog-overrides.json'; + draw(); + const trigger = screen.getByRole('button', { + name: 'settings.providers.models.advanced.imageInputOverride.label', + }); + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + fireEvent.click( + await screen.findByRole('menuitemradio', { + name: 'settings.providers.models.advanced.imageInputOverride.declaredTrue', + }), + ); + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + 'settings.providers.models.advanced.imageInputOverride.saveFailedWithReason', + ), + ); + }); + + it('covers every harness id of the row when declaring image capability', async () => { + // openai 行的主展示引擎是 codex(gpt-6),而运行期消费该能力的 Pi 侧 id 是 chatgpt/gpt-6。 + // 只写主引擎的 id 时 Pi 永远读不到声明,UI 却会显示「已声明」。 + const piModel = { ...model, id: 'chatgpt/gpt-6' }; + render( + null} + onDisable={vi.fn()} + disabled={false} + paymentRequired={false} + />, + ); + + const trigger = screen.getByRole('button', { + name: 'settings.providers.models.advanced.imageInputOverride.label', + }); + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + fireEvent.click( + await screen.findByRole('menuitemradio', { + name: 'settings.providers.models.advanced.imageInputOverride.declaredTrue', + }), + ); + await waitFor(() => expect(imageInputMocks.setValue).toHaveBeenCalledWith(true)); + // 声明目标必须带上 Pi 侧 id(写盘/读取都按该 id 精确匹配)。 + const targets = imageInputMocks.target.mock.calls.map((call) => call[0]); + expect(targets).toContainEqual( + expect.objectContaining({ + providerId: 'openai', + agent: 'codex', + modelId: 'gpt-6', + relatedTargets: [expect.objectContaining({ agent: 'pi', modelId: 'chatgpt/gpt-6' })], + }), + ); + }); + + it('warns when the limit exceeds a verified upstream window without a declared max', () => { + // 「已验证窗口但无 contextWindowMax」的模型此前不会告警:填到窗口以上也静默接受。 + mocks.limit = 1_200_000; + draw({ ...withoutCapacity(), contextWindow: 1_048_576, contextWindowVerified: true }); + expect( + screen.getByText('settings.providers.models.advanced.contextLimitOverWindow'), + ).toBeTruthy(); + }); + + it('does not offer the local declaration for the server-controlled gateway provider', () => { + render(drawer(model, model.defaultEffort, model.efforts, { ...provider, id: 'xd' })); + expect( + screen.getByRole('button', { + name: 'settings.providers.models.advanced.imageInputOverride.label', + }), + ).toHaveProperty('disabled', true); + }); + + it('does not warn about the limit exceeding an undeclared capacity', () => { + // 未声明容量时拿 200K 工作默认值当告警基线会误报「已超出当前显示的窗口」。 + mocks.limit = 1_200_000; + draw(withoutCapacity()); + expect(screen.queryByText('settings.providers.models.advanced.contextLimitOverWindow')).toBeNull(); + }); + it('keeps useful identity fields without exposing internal defaults, normal lifecycle or raw descriptions', () => { draw({ ...model, status: 'active', defaultEnabled: false, description: 'GPT for coding tasks' }); expect(screen.queryByText('active')).toBeNull(); diff --git a/apps/desktop/src/renderer/hooks/__tests__/useModelCatalogImageInput.test.ts b/apps/desktop/src/renderer/hooks/__tests__/useModelCatalogImageInput.test.ts new file mode 100644 index 00000000000..f8e172ca737 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/__tests__/useModelCatalogImageInput.test.ts @@ -0,0 +1,250 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useModelCatalogImageInput } from '../useModelCatalogImageInput'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const target = { agent: 'pi' as const, providerId: 'opencode-go', modelId: 'mimo-v2.6-flash' }; +const view = (value: boolean | null) => ({ value, isCustomized: value !== null }); +const get = vi.fn(); +const set = vi.fn(); +const ownerState = { current: true }; +let providersChanged: (() => void) | undefined; + +vi.mock('@/contexts/dataOwnerGeneration', () => ({ + getDataOwnerGeneration: () => ({ dataOwnerId: 'owner', ownerGeneration: 1 }), + isDataOwnerGenerationCurrent: () => ownerState.current, +})); + +beforeEach(() => { + vi.clearAllMocks(); + ownerState.current = true; + providersChanged = undefined; + Object.assign(window, { + electronAPI: { + maker: { + getModelCatalogImageInput: get, + setModelCatalogImageInput: set, + onProvidersChanged: (callback: () => void) => { + providersChanged = callback; + return () => {}; + }, + }, + }, + }); +}); + +describe('model catalog image input override', () => { + it('loads the current declaration', async () => { + get.mockResolvedValue(view(null)); + const hook = renderHook(() => useModelCatalogImageInput(target)); + await act(async () => {}); + expect(get).toHaveBeenCalledWith(target); + expect(hook.result.current).toMatchObject({ value: null, isCustomized: false, loading: false }); + }); + + it('applies the written declaration', async () => { + get.mockResolvedValue(view(null)); + set.mockResolvedValue(view(true)); + const hook = renderHook(() => useModelCatalogImageInput(target)); + await act(async () => {}); + let persisted = false; + await act(async () => { + persisted = await hook.result.current.setValue(true); + }); + expect(set).toHaveBeenCalledWith(target, true); + expect(persisted).toBe(true); + expect(hook.result.current).toMatchObject({ value: true, isCustomized: true, saving: false }); + }); + + it('reflects the new value before the write echoes back', async () => { + get.mockResolvedValue(view(null)); + const pending = deferred>(); + set.mockReturnValue(pending.promise); + const hook = renderHook(() => useModelCatalogImageInput(target)); + await act(async () => {}); + // 不等待回声:调用后立即就是新值(消除「点完卡一下」的观感)。 + act(() => { + void hook.result.current.setValue(true); + }); + expect(hook.result.current.value).toBe(true); + expect(hook.result.current.isCustomized).toBe(true); + expect(hook.result.current.saving).toBe(true); + await act(async () => { + pending.resolve(view(true)); + }); + expect(hook.result.current).toMatchObject({ value: true, saving: false }); + }); + + it('reverts to the committed value when the write fails instead of keeping the optimistic one', async () => { + get.mockResolvedValue(view(false)); + set.mockRejectedValue(new Error('persist failed')); + const hook = renderHook(() => useModelCatalogImageInput(target)); + await act(async () => {}); + let persisted = true; + await act(async () => { + persisted = await hook.result.current.setValue(true); + }); + // 回读拿到的真值(false)必须盖掉乐观值,并标 error;返回值告诉调用方别提示成功。 + expect(persisted).toBe(false); + expect(hook.result.current).toMatchObject({ value: false, isCustomized: true, error: true }); + }); + + it('falls back to "follow the catalog" when both the write and the recovery read fail', async () => { + get.mockResolvedValueOnce(view(true)).mockRejectedValue(new Error('read failed')); + set.mockRejectedValue(new Error('persist failed')); + const hook = renderHook(() => useModelCatalogImageInput(target)); + await act(async () => {}); + await act(async () => { + await hook.result.current.setValue(null); + }); + expect(hook.result.current).toMatchObject({ value: null, isCustomized: false, error: true }); + }); + + it('ignores broadcasts that arrive while a write is in flight', async () => { + // 写入成功后 main 会广播 PROVIDER_CHANGED;而其它窗口期的广播可能在写盘前就到达。 + // 若让它触发的 GET 顶掉 generation,写入自己的回声会被丢弃,UI 停在旧值而写其实成功。 + get.mockResolvedValue(view(null)); + const pending = deferred>(); + set.mockReturnValue(pending.promise); + const hook = renderHook(() => useModelCatalogImageInput(target)); + await act(async () => {}); + const callsAfterMount = get.mock.calls.length; + + act(() => { + void hook.result.current.setValue(true); + }); + expect(hook.result.current.value).toBe(true); + + // 写在途时的广播:不得再发 GET(发了就会抢走写入的回声)。 + await act(async () => { + providersChanged?.(); + }); + expect(get.mock.calls.length).toBe(callsAfterMount); + + await act(async () => { + pending.resolve(view(true)); + }); + expect(hook.result.current).toMatchObject({ value: true, isCustomized: true, saving: false }); + }); + + it('still reads a different model while a write for another row is in flight', async () => { + // 写在途时跳过回读只对同一个 target 生效:否则写 A 行后立刻打开 B 行,B 的回读会被跳过、 + // 界面永远停在 loading(显示成「跟随供应商」且点击无效)。 + const pendingWrite = deferred>(); + get.mockResolvedValue(view(null)); + set.mockReturnValue(pendingWrite.promise); + const hook = renderHook(({ modelId }) => useModelCatalogImageInput({ ...target, modelId }), { + initialProps: { modelId: 'mimo-v2.6-flash' }, + }); + await act(async () => {}); + act(() => { + void hook.result.current.setValue(true); + }); + + get.mockResolvedValue(view(false)); + await act(async () => { + hook.rerender({ modelId: 'gpt-6' }); + }); + // 新行的读取没有被在途写吞掉,拿到的是自己的值。 + expect(get).toHaveBeenLastCalledWith({ ...target, modelId: 'gpt-6' }); + expect(hook.result.current).toMatchObject({ + value: false, + isCustomized: true, + loading: false, + }); + + await act(async () => { + pendingWrite.resolve(view(true)); + }); + hook.unmount(); + }); + + it('re-reads the row when the user comes back while its write is still in flight', async () => { + // 写 A 未完成 → 切 B → 切回 A:A 的首次读取当时被跳过,旧写入回声又被 generation 守卫 + // 丢弃。不补读的话 A 会永远停在 loading 占位(显示成「跟随供应商」且点击无效)。 + const pendingWrite = deferred>(); + get.mockResolvedValue(view(null)); + set.mockReturnValue(pendingWrite.promise); + const hook = renderHook(({ modelId }) => useModelCatalogImageInput({ ...target, modelId }), { + initialProps: { modelId: 'mimo-v2.6-flash' }, + }); + await act(async () => {}); + act(() => { + void hook.result.current.setValue(true); + }); + + await act(async () => { + hook.rerender({ modelId: 'gpt-6' }); + }); + get.mockResolvedValue(view(false)); + await act(async () => { + hook.rerender({ modelId: 'mimo-v2.6-flash' }); + }); + + const readsBefore = get.mock.calls.length; + await act(async () => { + pendingWrite.resolve(view(true)); + }); + // 写入落定时回声已被丢弃:必须补一次读取,界面不能停在 loading。 + expect(get.mock.calls.length).toBeGreaterThan(readsBefore); + expect(hook.result.current).toMatchObject({ loading: false }); + hook.unmount(); + }); + + it('surfaces the main-side failure reason instead of only reporting false', async () => { + // main 在这条路径上带可执行指引(手改文件里有无法保留的条目时要先修文件); + // 只回 false 会让用户反复保存失败却看不到唯一的修复方式。 + get.mockResolvedValue(view(null)); + set.mockRejectedValue( + Object.assign( + new Error('[PRECONDITION_FAILED] 请先修正 model-catalog-overrides.json'), + { code: 'PRECONDITION_FAILED' }, + ), + ); + const hook = renderHook(() => useModelCatalogImageInput(target)); + await act(async () => {}); + await act(async () => { + await hook.result.current.setValue(true); + }); + // 展示用文案去掉机器码前缀。 + expect(hook.result.current.errorReason).toBe('请先修正 model-catalog-overrides.json'); + }); + + it('drops a response that lands after the data owner changed', async () => { + const pending = deferred>(); + get.mockReturnValue(pending.promise); + const hook = renderHook(() => useModelCatalogImageInput(target)); + + // 切号(账号/session owner 代次变化)后旧响应不得写进状态。 + ownerState.current = false; + await act(async () => { + pending.resolve(view(true)); + }); + expect(hook.result.current.value).toBeNull(); + }); + + it('does not let a stale read for a previous model win', async () => { + const stale = deferred>(); + get.mockReturnValueOnce(stale.promise).mockResolvedValueOnce(view(true)); + const hook = renderHook(({ modelId }) => useModelCatalogImageInput({ ...target, modelId }), { + initialProps: { modelId: 'old' }, + }); + await act(async () => { + hook.rerender({ modelId: 'new' }); + }); + expect(hook.result.current.value).toBe(true); + await act(async () => { + stale.resolve(view(false)); + }); + expect(hook.result.current.value).toBe(true); + hook.unmount(); + }); +}); diff --git a/apps/desktop/src/renderer/hooks/__tests__/useModelCatalogThinking.test.ts b/apps/desktop/src/renderer/hooks/__tests__/useModelCatalogThinking.test.ts new file mode 100644 index 00000000000..938dad71092 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/__tests__/useModelCatalogThinking.test.ts @@ -0,0 +1,138 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useModelCatalogThinking } from '../useModelCatalogThinking'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const get = vi.fn(); +const set = vi.fn(); +const ownerState = { current: true }; +let subscriptions = 0; +let unsubscribes = 0; +let broadcast: (() => void) | undefined; + +vi.mock('@/contexts/dataOwnerGeneration', () => ({ + getDataOwnerGeneration: () => ({ dataOwnerId: 'owner', ownerGeneration: 1 }), + isDataOwnerGenerationCurrent: () => ownerState.current, +})); + +beforeEach(() => { + vi.clearAllMocks(); + ownerState.current = true; + subscriptions = 0; + unsubscribes = 0; + broadcast = undefined; + Object.assign(window, { + electronAPI: { + maker: { + getModelCatalogThinking: get, + setModelCatalogThinking: set, + onProvidersChanged: (callback: () => void) => { + subscriptions += 1; + broadcast = callback; + return () => { + unsubscribes += 1; + }; + }, + }, + }, + }); +}); + +const target = { agent: 'pi' as const, providerId: 'p', modelId: 'm' }; +type Props = { target: typeof target | null }; + +describe('model catalog thinking override hook', () => { + it('returns the cleanup on the empty-target cycle too, so open/close cycles do not leak subscriptions', async () => { + // 回归(greptile P2):抽屉关闭(target=null)的 effect 周期先前只订阅、直接 return, + // 不交还清理函数 —— 每开关一次抽屉泄漏一个 onProvidersChanged 订阅。泄漏回调在之后的 + // 保存广播里调 run():setState(EMPTY) 清掉勾选、递增 generation 顶掉写入回声,补读前 + // 界面错误显示「跟随供应商」。 + get.mockResolvedValue({ tiers: ['low'], defaultEffort: 'low', isCustomized: false }); + const hook = renderHook((props: Props) => useModelCatalogThinking(props.target), { + initialProps: { target } as Props, + }); + await act(async () => {}); + expect(subscriptions).toBe(1); + + // 开 → 关 → 再开:每个 effect 周期留下的订阅都必须在其清理函数里注销。 + hook.rerender({ target: null }); + await act(async () => {}); + hook.rerender({ target }); + await act(async () => {}); + expect(subscriptions).toBe(3); + expect(unsubscribes).toBe(2); // 前两个周期已注销;泄漏时这里是 1 + + hook.unmount(); + expect(unsubscribes).toBe(subscriptions); // 卸载后一个不剩 + }); + + it('a broadcast after reopen reads through exactly once instead of being clobbered by a stale callback', async () => { + // 泄漏的可观察后果:重开后的广播会同时触发泄漏回调(空目标 → setState(EMPTY))与当前 + // 回调(真读回),EMPTY 后到还会把读回值盖掉。修复后每个周期只有一份订阅,广播恰好 + // 触发一次 GET,界面停在真值上。 + get.mockResolvedValue({ tiers: ['low'], defaultEffort: 'low', isCustomized: false }); + const hook = renderHook((props: Props) => useModelCatalogThinking(props.target), { + initialProps: { target } as Props, + }); + await act(async () => {}); + hook.rerender({ target: null }); + await act(async () => {}); + hook.rerender({ target }); + await act(async () => {}); + get.mockClear(); + + await act(async () => { + broadcast?.(); + }); + expect(get).toHaveBeenCalledTimes(1); + expect(hook.result.current).toMatchObject({ tiers: ['low'], loading: false, error: false }); + }); + + it('keeps the committed defaultEffort in optimistic state so a consecutive checkbox write does not null it', async () => { + // 回归(greptile P1):乐观状态先前只保留 value/isCustomized,丢掉 defaultEffort。 + // 下拉菜单在保存期间保持可用(多选不自动关),用户在第一笔落盘前再勾一档时,抽屉 + // 拿 state.defaultEffort 当 keptDefault —— 丢了它,第二笔就会把 null 写回文件, + // 静默清掉用户原有的默认档。 + get.mockResolvedValue({ tiers: ['low'], defaultEffort: 'low', isCustomized: false }); + const firstWrite = deferred(); + let firstSettled = false; + set.mockImplementationOnce(() => { + firstSettled = true; + void firstWrite; + return firstWrite.promise as Promise; + }); + const hook = renderHook((props: Props) => useModelCatalogThinking(props.target), { + initialProps: { target } as Props, + }); + await act(async () => {}); + + // 第一笔写入保持在途;等 React 冲出乐观渲染后,模拟用户再勾一档(抽屉按当前 state + // 计算 keptDefault)。第二笔立即落盘。 + let second: Promise | undefined; + await act(async () => { + void hook.result.current.setTiers(['low', 'high'], 'low'); + }); + const keptDefault = hook.result.current.defaultEffort; + set.mockResolvedValue({ tiers: ['low', 'high', 'max'], defaultEffort: 'low', isCustomized: true }); + await act(async () => { + second = hook.result.current.setTiers(['low', 'high', 'max'], keptDefault); + await second; + }); + await act(async () => { + firstWrite.resolve(); + }); + expect(firstSettled).toBe(true); + + // 第二笔的默认档必须还是 low(被乐观状态保留),而不是 null(被丢掉后抽屉传回 null)。 + expect(keptDefault).toBe('low'); + expect(set.mock.calls[1]).toEqual([target, ['low', 'high', 'max'], 'low']); + }); +}); diff --git a/apps/desktop/src/renderer/hooks/useModelCatalogImageInput.ts b/apps/desktop/src/renderer/hooks/useModelCatalogImageInput.ts new file mode 100644 index 00000000000..5a5964dd1ad --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useModelCatalogImageInput.ts @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + getDataOwnerGeneration, + isDataOwnerGenerationCurrent, +} from '@/contexts/dataOwnerGeneration'; +import { createLogger } from '@/lib/logger'; +import type { + ModelCatalogImageInputTarget, + ModelCatalogImageInputView, +} from '../../shared/modelCatalogImageInput'; + +const log = createLogger('UseModelCatalogImageInput'); +const EMPTY: ModelCatalogImageInputView = { value: null, isCustomized: false }; + +/** + * 取给用户看的失败原因:main 的 `[CODE] message` 去掉机器码前缀。main 只在这条路径上带 + * 可执行指引(如「先修正 model-catalog-overrides.json」),把它吞掉会让用户反复保存失败 + * 却看不到唯一的修复方式。 + */ +function toDisplayReason(error: unknown): string | undefined { + const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; + const message = raw.replace(/^\[[A-Z0-9_]+\]\s*/, '').trim(); + return message.length > 0 ? message : undefined; +} + +/** + * 单模型图片输入能力的本地目录 override。 + * + * 与 `useModelContextLimit` 同一套纪律:请求不跨 target 存活(generation 守卫)、不跨账号 + * 存活(owner 守卫)、失败时回读真值而不是把乐观值留在界面上。写入成功后 main 会广播 + * PROVIDER_CHANGED,这里同时订阅该事件,让「跟随供应商」的括注随目录变化刷新。 + */ +export function useModelCatalogImageInput(target: ModelCatalogImageInputTarget | null) { + const key = JSON.stringify(target); + const stableTarget = useMemo(() => JSON.parse(key), [key]); + const [state, setState] = useState({ + ...EMPTY, + key, + loading: true, + saving: false, + error: false, + errorReason: undefined as string | undefined, + }); + const generation = useRef(0); + /** 在途写入数:广播触发的回读可能在写盘前拿到旧值,不得顶掉写入自己的回声。 */ + const writesInFlight = useRef(0); + /** 在途写入的目标 key:只有在途写与本次读取是同一个 target 时才跳过回读(见下)。 */ + const writesInFlightKey = useRef(null); + /** 当前渲染周期的 target key 与 run:写入落定时用它判断「用户是否已经切回/切走」。 */ + const latestKey = useRef(key); + latestKey.current = key; + const latestRun = useRef<(write?: { value: boolean | null }) => Promise>(async () => true); + + const run = useCallback( + async (write?: { value: boolean | null }): Promise => { + // 写在途时不发 GET:广播(含本次写入自己触发的那次)可能在写盘前到达,其结果比写入的 + // 回声旧,却会顶掉 generation 使写入结果被丢弃 —— UI 会停在旧值而写其实已经成功。 + // 只对**同一个 target**的在途写生效:否则刚写 A 行就打开 B 行时,B 的回读会被跳过、 + // 界面永远停在 loading(显示成「跟随供应商」且点击无效)。 + if (!write && writesInFlight.current > 0 && writesInFlightKey.current === key) return true; + const request = ++generation.current; + if (!stableTarget) { + setState({ ...EMPTY, key, loading: false, saving: false, error: false, errorReason: undefined }); + return false; + } + const owner = getDataOwnerGeneration(); + const current = () => request === generation.current && isDataOwnerGenerationCurrent(owner); + // 乐观更新:写入时立即反映新值,标签点击即变;失败时由下方的回读把真值盖回来, + // 不会把乐观值留在界面上冒充已保存。刷新(GET)保留原值,不产生明暗/文案跳变。 + setState((prev) => ({ + ...(write ? { value: write.value, isCustomized: write.value !== null } : prev), + key, + loading: !write, + saving: write !== undefined, + error: false, + errorReason: undefined, + })); + if (write) { + writesInFlight.current += 1; + writesInFlightKey.current = key; + } + // 写入回声是否真的写进了状态(generation 被切走/切回就会丢)。 + let echoLanded = true; + try { + const view = write + ? await window.electronAPI.maker.setModelCatalogImageInput(stableTarget, write.value) + : await window.electronAPI.maker.getModelCatalogImageInput(stableTarget); + echoLanded = current(); + if (echoLanded) { + setState({ ...view, key, loading: false, saving: false, error: false, errorReason: undefined }); + } + return true; + } catch (error) { + log.warn('model catalog image input request failed', error); + // 带给人看的失败原因(main 在这条路径上会给出可执行指引),不再只回一个 false。 + const reason = toDisplayReason(error); + // 失败时回读已提交的值;回读也失败就退回「跟随供应商」并标记 error, + // 绝不让乐观值留在界面上冒充已保存。 + if (write && current()) { + try { + const view = await window.electronAPI.maker.getModelCatalogImageInput(stableTarget); + echoLanded = current(); + if (echoLanded) { + setState({ ...view, key, loading: false, saving: false, error: true, errorReason: reason }); + } + return false; + } catch (readError) { + log.warn('model catalog image input recovery read failed', readError); + } + } + echoLanded = current(); + if (echoLanded) { + setState({ ...EMPTY, key, loading: false, saving: false, error: true, errorReason: reason }); + } + return false; + } finally { + if (write) { + writesInFlight.current -= 1; + if (writesInFlight.current <= 0) writesInFlightKey.current = null; + // 写入回声丢失且用户又回到同一行(写 A → 切 B → 切回 A):这行的首次读取当时被 + // 跳过、回声又被 generation 守卫丢弃,界面会永远停在 loading 占位。补一次读取。 + // 用户停在其他行时不需要补:那一行的 key 不同,本就没有被跳过。 + if (!echoLanded && latestKey.current === key) void latestRun.current(); + } + } + }, + [stableTarget, key], + ); + latestRun.current = run; + + useEffect(() => { + setState({ + ...EMPTY, + key, + loading: stableTarget !== null, + saving: false, + error: false, + errorReason: undefined, + }); + const unsubscribe = window.electronAPI?.maker?.onProvidersChanged?.(() => { + void run(); + }); + void run(); + return () => { + unsubscribe?.(); + generation.current += 1; + }; + }, [run, stableTarget, key]); + + /** 返回值 = 是否真正落盘。调用方据此提示失败,不要读渲染期的 error(stale closure)。 */ + const setValue = useCallback((value: boolean | null) => run({ value }), [run]); + return { + ...(state.key === key + ? state + : { ...EMPTY, loading: true, saving: false, error: false, errorReason: undefined }), + setValue, + }; +} diff --git a/apps/desktop/src/renderer/hooks/useModelCatalogThinking.ts b/apps/desktop/src/renderer/hooks/useModelCatalogThinking.ts new file mode 100644 index 00000000000..525d7ced2ca --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useModelCatalogThinking.ts @@ -0,0 +1,224 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + getDataOwnerGeneration, + isDataOwnerGenerationCurrent, +} from '@/contexts/dataOwnerGeneration'; +import { createLogger } from '@/lib/logger'; +import type { + ModelCatalogThinkingTarget, + ModelCatalogThinkingView, +} from '../../shared/modelCatalogThinking'; + +const log = createLogger('UseModelCatalogThinking'); +const EMPTY: ModelCatalogThinkingView = { value: null, defaultEffort: null, isCustomized: false }; + +/** + * 取给用户看的失败原因:main 的 `[CODE] message` 去掉机器码前缀。main 只在这条路径上带 + * 可执行指引(如「先修正 model-catalog-overrides.json」),把它吞掉会让用户反复保存失败 + * 却看不到唯一的修复方式。 + */ +function toDisplayReason(error: unknown): string | undefined { + const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; + const message = raw.replace(/^\[[A-Z0-9_]+\]\s*/, '').trim(); + return message.length > 0 ? message : undefined; +} + +/** + * 单模型思考档位的本地目录 override。 + * + * 与 `useModelCatalogImageInput` 同一套纪律:请求不跨 target 存活(generation 守卫)、 + * 不跨账号存活(owner 守卫)、失败时回读真值而不是把乐观值留在界面上。写入成功后 main 会 + * 广播 PROVIDER_CHANGED,这里同时订阅该事件,让「跟随供应商」的括注随目录变化刷新。 + * + * 语义差异:档位是**数组**而非布尔,所以乐观值是整组档位 + 可选默认档;「跟随供应商」 + * 用 `null` 表达(与图片输入的 `value: null` 同义)。 + */ +export function useModelCatalogThinking(target: ModelCatalogThinkingTarget | null) { + const key = JSON.stringify(target); + const stableTarget = useMemo(() => JSON.parse(key), [key]); + const [state, setState] = useState({ + ...EMPTY, + key, + loading: true, + /** 首次读回是否完成:未完成时界面上的空值只是占位,不能据此写入。 */ + loaded: false, + saving: false, + error: false, + errorReason: undefined as string | undefined, + }); + const generation = useRef(0); + /** 在途写入数:广播触发的回读可能在写盘前拿到旧值,不得顶掉写入自己的回声。 */ + const writesInFlight = useRef(0); + /** 在途写入的目标 key:只有在途写与本次读取是同一个 target 时才跳过回读(见下)。 */ + const writesInFlightKey = useRef(null); + /** 当前渲染周期的 target key 与 run:写入落定时用它判断「用户是否已经切回/切走」。 */ + const latestKey = useRef(key); + latestKey.current = key; + const latestRun = useRef<(write?: WriteShape) => Promise>(async () => true); + + type WriteShape = { tiers: string[] | null; defaultTier: string | null }; + + const run = useCallback( + async (write?: WriteShape): Promise => { + // 写在途时不发 GET:广播(含本次写入自己触发的那次)可能在写盘前到达,其结果比写入的 + // 回声旧,却会顶掉 generation 使写入结果被丢弃 —— UI 会停在旧值而写其实已经成功。 + if (!write && writesInFlight.current > 0 && writesInFlightKey.current === key) return true; + const request = ++generation.current; + if (!stableTarget) { + setState({ + ...EMPTY, + key, + loading: false, + loaded: true, + saving: false, + error: false, + errorReason: undefined, + }); + return false; + } + const owner = getDataOwnerGeneration(); + const current = () => request === generation.current && isDataOwnerGenerationCurrent(owner); + // 乐观更新:写入时立即反映新值;失败时由下方的回读把真值盖回来,不会把乐观值留在 + // 界面上冒充已保存。刷新(GET)保留原值,不产生文案跳变。 + setState((prev) => ({ + ...(write + ? { + value: write.tiers, + isCustomized: write.tiers !== null, + // 乐观期必须保留本次提交的默认档:下拉菜单在保存期间保持可用(多选不自动关), + // 用户在第一笔落盘前再勾一档时,抽屉会拿 state.defaultEffort 当 keptDefault —— + // 这里丢了它,第二笔就会把 null 写回文件,静默清掉用户原有的默认档。 + defaultEffort: write.defaultTier, + } + : prev), + key, + // 首次读回前就写入也不会回退到「未加载」:loaded 是「已拿到过真值」的意思, + // 乐观写入本身就是一次提交,不该把控件重新锁上。 + loaded: write ? true : prev.loaded, + loading: !write, + saving: write !== undefined, + error: false, + errorReason: undefined, + })); + if (write) { + writesInFlight.current += 1; + writesInFlightKey.current = key; + } + // 写入回声是否真的写进了状态(generation 被切走/切回就会丢)。 + let echoLanded = true; + try { + const view = write + ? await (async () => { + await window.electronAPI.maker.setModelCatalogThinking( + stableTarget, + write.tiers, + write.defaultTier, + ); + return window.electronAPI.maker.getModelCatalogThinking(stableTarget); + })() + : await window.electronAPI.maker.getModelCatalogThinking(stableTarget); + echoLanded = current(); + if (echoLanded) { + setState({ + ...view, + key, + loading: false, + loaded: true, + saving: false, + error: false, + errorReason: undefined, + }); + } + return true; + } catch (error) { + log.warn('model catalog thinking request failed', error); + const reason = toDisplayReason(error); + // 失败时回读已提交的值;回读也失败就退回「跟随供应商」并标记 error, + // 绝不让乐观值留在界面上冒充已保存。 + if (write && current()) { + try { + const view = await window.electronAPI.maker.getModelCatalogThinking(stableTarget); + echoLanded = current(); + if (echoLanded) { + setState({ + ...view, + key, + loading: false, + loaded: true, + saving: false, + error: true, + errorReason: reason, + }); + } + return false; + } catch (readError) { + log.warn('model catalog thinking recovery read failed', readError); + } + } + echoLanded = current(); + if (echoLanded) { + setState({ + ...EMPTY, + key, + loading: false, + loaded: true, + saving: false, + error: true, + errorReason: reason, + }); + } + return false; + } finally { + if (write) { + writesInFlight.current -= 1; + if (writesInFlight.current <= 0) writesInFlightKey.current = null; + // 写入回声丢失且用户又回到同一行(写 A → 切 B → 切回 A):这行的首次读取当时被 + // 跳过、回声又被 generation 守卫丢弃,界面会永远停在 loading 占位。补一次读取。 + if (!echoLanded && latestKey.current === key) void latestRun.current(); + } + } + }, + [stableTarget, key], + ); + latestRun.current = run; + + useEffect(() => { + setState({ + ...EMPTY, + key, + loading: stableTarget !== null, + loaded: false, + saving: false, + error: false, + errorReason: undefined, + }); + // 目录刷新(远端 sync / 账号切换)后重新读回:跟随供应商的显示值可能已经变了。 + // 可选链是必需的:设置抽屉的既有测试只桩了部分 electronAPI,硬调用会让整个抽屉崩掉 + // (与 useModelCatalogImageInput 同一处理)。 + const unsubscribe = window.electronAPI?.maker?.onProvidersChanged?.(() => { + void run(); + }); + // 订阅在任何 target 状态下都必须交还清理函数:抽屉关闭(target=null)时若直接 return, + // 订阅会逐次开关累积泄漏 —— 之后每次保存广播都会让泄漏回调调 run(),它 setState(EMPTY) + // 并递增 generation,把正在保存的回声顶掉,补读前界面会错误显示「跟随供应商」。 + // 空目标只跳过初始 GET(run() 内部对空目标本就是 no-op)。 + if (!stableTarget) { + return () => { + unsubscribe?.(); + }; + } + void run(); + return () => { + unsubscribe?.(); + generation.current += 1; + }; + }, [run, stableTarget, key]); + + return { + ...state, + /** 声明一组档位;`tiers=null` 恢复「跟随供应商」。 */ + setTiers: (tiers: string[] | null, defaultTier: string | null = null) => + run({ tiers, defaultTier }), + }; +} diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 402707264c5..45a7321742c 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -1241,9 +1241,10 @@ "MODEL_SWITCH_TASK_RUNNING": "Model not switched: this task is still running. Wait for the current response to finish, then try again.", "MODEL_WINDOW_PREPARATION_IN_PROGRESS": "Model not switched: Cindy is already preparing this task’s context. Wait for it to finish, then try again.", "SESSION_RUNNING": "This session is currently running a task. Please try again shortly.", - "PI_IMAGE_INPUT_UNSUPPORTED": "Image input is not enabled for the current Pi model. Switch to a model that supports images. For a custom model, enable “Supports image input” in provider settings, then start a new Pi task.", + "PI_IMAGE_INPUT_UNSUPPORTED": "Image input is not enabled for the current Pi model. Switch to a model that supports images. For a custom model, enable “Supports image input” in provider settings, then switch the model once or start a new Pi task.", "CREDENTIAL_SWITCH_BUSY": "Another Codex task is still running, so this provider switch was not applied (the session keeps its previous provider). Try again after those tasks finish.", - "PI_CATALOG_RELOAD_UNCONFIRMED": "Model catalog reload was not confirmed, so this task was stopped. Reopen the task and switch models again.", + "PI_CATALOG_RELOAD_UNCONFIRMED": "Model catalog reload was not confirmed, so this task was stopped. Reopen the task and retry.", + "PI_IMAGE_CAPABILITY_REFRESH_FAILED": "Failed to apply the image input capability change. The change was rolled back or will be retried; retry the action, and reopen the task if it keeps failing.", "REMOTE_WORKDIR_INVALID": "The remote project path is invalid. Choose the project directory again.", "REMOTE_WORKDIR_NOT_FOUND": "The remote project directory no longer exists. Choose it again.", "REMOTE_WORKDIR_NOT_DIRECTORY": "The remote project path is not a folder. Choose a project directory.", @@ -3760,6 +3761,14 @@ }, "capabilityHint": "Uses capabilities declared by this provider. Image support falls back to model naming when undeclared.", "imageInput": "Image input", + "imageInputOverride": { + "label": "Image input capability", + "inherit": "Follow provider", + "declaredTrue": "Supports image input", + "declaredFalse": "No image input", + "saveFailed": "Failed to save image input capability", + "saveFailedWithReason": "Failed to save image input capability: {{message}}" + }, "supported": "Supported", "unsupported": "Not supported", "undeclared": "Not declared", @@ -3835,7 +3844,20 @@ }, "codexContextUnknown": "CLI configuration unavailable", "codexContextValues": "Total {{total}} · Usable {{usable}} · Auto-compact {{compact}} tokens", - "contextLimitGptHint": "GPT defaults to at most 272K; you can set it to 1M here. Codex applies the window and automatic compaction threshold together, retaining 5% headroom. Actual capacity depends on the model service." + "contextLimitGptHint": "GPT defaults to at most 272K; you can set it to 1M here. Codex applies the window and automatic compaction threshold together, retaining 5% headroom. Actual capacity depends on the model service.", + "thinking": "Thinking", + "thinkingOverride": { + "label": "Declare thinking tiers", + "inherit": "Follow provider", + "declared": "Declared", + "sourceHint": "The catalog reports no thinking tiers; declare them here if the model actually reasons", + "saveFailed": "Failed to save the thinking declaration", + "saveFailedWithReason": "Failed to save the thinking declaration: {{reason}}" + }, + "thinkingCapability": { + "on": "Supports thinking", + "off": "No thinking" + } }, "kindFilter": { "all": "All", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 15c4b50d8da..7c28f59da25 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -1229,9 +1229,10 @@ "MODEL_SWITCH_TASK_RUNNING": "モデルは切り替わっていません。現在のタスクはまだ実行中です。今回の応答が完了してから再試行してください。", "MODEL_WINDOW_PREPARATION_IN_PROGRESS": "モデルは切り替わっていません。このタスクのコンテキストを整理しています。完了してから再試行してください。", "SESSION_RUNNING": "このセッションは現在タスクを実行中です。しばらくしてからもう一度お試しください。", - "PI_IMAGE_INPUT_UNSUPPORTED": "現在の Pi モデルでは画像入力が有効になっていません。画像に対応するモデルへ切り替えてください。カスタムモデルの場合は、プロバイダー設定で「画像入力に対応」を有効にしてから、新しい Pi タスクを開始してください。", + "PI_IMAGE_INPUT_UNSUPPORTED": "現在の Pi モデルでは画像入力が有効になっていません。画像に対応するモデルへ切り替えてください。カスタムモデルの場合は、プロバイダー設定で「画像入力に対応」を有効にしてから、モデルを一度切り替えるか、新しい Pi タスクを開始してください。", "CREDENTIAL_SWITCH_BUSY": "他の Codex タスクが実行中のため、今回のプロバイダー切り替えは適用されませんでした(セッションは元のプロバイダーのままです)。他のタスクが終わってからもう一度お試しください。", - "PI_CATALOG_RELOAD_UNCONFIRMED": "モデルカタログの再読み込みを確認できなかったため、このタスクを終了しました。タスクを開き直してからモデルを切り替えてください。", + "PI_CATALOG_RELOAD_UNCONFIRMED": "モデルカタログの再読み込みを確認できなかったため、このタスクを終了しました。タスクを開き直してから再試行してください。", + "PI_IMAGE_CAPABILITY_REFRESH_FAILED": "画像入力能力の変更を適用できませんでした。変更はロールバックされたか、あとで再試行されます。もう一度操作するか、改善しない場合はタスクを開き直してください。", "REMOTE_WORKDIR_INVALID": "リモートプロジェクトのパスが無効です。プロジェクトディレクトリを選び直してください。", "REMOTE_WORKDIR_NOT_FOUND": "リモートプロジェクトのディレクトリが見つかりません。選び直してください。", "REMOTE_WORKDIR_NOT_DIRECTORY": "リモートプロジェクトのパスはディレクトリではありません。プロジェクトディレクトリを選び直してください。", @@ -3730,6 +3731,14 @@ }, "capabilityHint": "このプロバイダーが宣言した機能を優先します。画像対応が未宣言の場合はモデル名から推定します。", "imageInput": "画像入力", + "imageInputOverride": { + "label": "画像入力能力の宣言", + "inherit": "プロバイダーに従う", + "declaredTrue": "画像入力に対応", + "declaredFalse": "画像入力に非対応", + "saveFailed": "画像入力能力の保存に失敗しました", + "saveFailedWithReason": "画像入力能力の保存に失敗しました:{{message}}" + }, "supported": "対応", "unsupported": "非対応", "undeclared": "宣言なし", @@ -3805,7 +3814,20 @@ }, "codexContextUnknown": "CLI 設定を取得できません", "codexContextValues": "総容量 {{total}} · 使用可能 {{usable}} · 自動圧縮 {{compact}} tokens", - "contextLimitGptHint": "GPT は既定で最大 272K を使用します。ここで 1M に変更できます。Codex はウィンドウと自動圧縮のしきい値を同時に適用し、5% の余裕を確保します。実際の容量はモデルサービスに依存します。" + "contextLimitGptHint": "GPT は既定で最大 272K を使用します。ここで 1M に変更できます。Codex はウィンドウと自動圧縮のしきい値を同時に適用し、5% の余裕を確保します。実際の容量はモデルサービスに依存します。", + "thinking": "思考", + "thinkingOverride": { + "label": "思考ティアを宣言", + "inherit": "プロバイダーに従う", + "declared": "宣言済み", + "sourceHint": "カタログに思考ティアの宣言がありません。モデルが実際に推論する場合はここで宣言できます", + "saveFailed": "思考の宣言を保存できませんでした", + "saveFailedWithReason": "思考の宣言を保存できませんでした: {{reason}}" + }, + "thinkingCapability": { + "on": "思考に対応", + "off": "思考に非対応" + } }, "kindFilter": { "all": "すべて", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 4a73770ea06..cfbbd759150 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -1229,9 +1229,10 @@ "MODEL_SWITCH_TASK_RUNNING": "모델이 전환되지 않았습니다. 현재 작업이 아직 실행 중입니다. 이번 응답이 끝난 후 다시 시도하세요.", "MODEL_WINDOW_PREPARATION_IN_PROGRESS": "모델이 전환되지 않았습니다. 현재 작업의 컨텍스트를 정리하고 있습니다. 완료된 후 다시 시도하세요.", "SESSION_RUNNING": "이 세션은 현재 작업을 실행 중입니다. 잠시 후 다시 시도해 주세요.", - "PI_IMAGE_INPUT_UNSUPPORTED": "현재 Pi 모델에는 이미지 입력이 활성화되어 있지 않습니다. 이미지를 지원하는 모델로 전환하세요. 사용자 지정 모델이라면 제공자 설정에서 ‘이미지 입력 지원’을 켠 다음 새 Pi 작업을 시작하세요.", + "PI_IMAGE_INPUT_UNSUPPORTED": "현재 Pi 모델에는 이미지 입력이 활성화되어 있지 않습니다. 이미지를 지원하는 모델로 전환하세요. 사용자 지정 모델이라면 제공자 설정에서 ‘이미지 입력 지원’을 켠 다음 모델을 한 번 전환하거나 새 Pi 작업을 시작하세요.", "CREDENTIAL_SWITCH_BUSY": "다른 Codex 작업이 실행 중이어서 이번 제공자 전환은 적용되지 않았습니다(세션은 기존 제공자를 유지합니다). 다른 작업이 끝난 후 다시 시도해 주세요.", - "PI_CATALOG_RELOAD_UNCONFIRMED": "모델 카탈로그 다시 로드가 확인되지 않아 이 작업을 종료했습니다. 작업을 다시 연 뒤 모델을 전환하세요.", + "PI_CATALOG_RELOAD_UNCONFIRMED": "모델 카탈로그 다시 로드가 확인되지 않아 이 작업을 종료했습니다. 작업을 다시 연 뒤 다시 시도하세요.", + "PI_IMAGE_CAPABILITY_REFRESH_FAILED": "이미지 입력 능력 변경을 적용하지 못했습니다. 변경이 롤백되었거나 나중에 다시 시도됩니다. 다시 시도하고, 계속 실패하면 작업을 다시 열어 주세요.", "REMOTE_WORKDIR_INVALID": "원격 프로젝트 경로가 올바르지 않습니다. 프로젝트 폴더를 다시 선택하세요.", "REMOTE_WORKDIR_NOT_FOUND": "원격 프로젝트 폴더가 없습니다. 프로젝트 폴더를 다시 선택하세요.", "REMOTE_WORKDIR_NOT_DIRECTORY": "원격 프로젝트 경로가 폴더가 아닙니다. 프로젝트 폴더를 다시 선택하세요.", @@ -3730,6 +3731,14 @@ }, "capabilityHint": "이 공급업체가 명시한 기능을 우선합니다. 이미지 지원 정보가 없으면 모델 이름으로 추정합니다.", "imageInput": "이미지 입력", + "imageInputOverride": { + "label": "이미지 입력 능력 선언", + "inherit": "제공자 따르기", + "declaredTrue": "이미지 입력 지원", + "declaredFalse": "이미지 입력 미지원", + "saveFailed": "이미지 입력 능력 저장 실패", + "saveFailedWithReason": "이미지 입력 능력 저장 실패: {{message}}" + }, "supported": "지원", "unsupported": "지원 안 함", "undeclared": "선언되지 않음", @@ -3805,7 +3814,20 @@ }, "codexContextUnknown": "CLI 설정을 읽을 수 없음", "codexContextValues": "전체 {{total}} · 사용 가능 {{usable}} · 자동 압축 {{compact}} tokens", - "contextLimitGptHint": "GPT는 기본적으로 최대 272K를 사용하며 여기서 1M으로 변경할 수 있습니다. Codex는 창과 자동 압축 임계값을 함께 적용하고 5%의 여유를 유지합니다. 실제 용량은 모델 서비스에 따라 다릅니다." + "contextLimitGptHint": "GPT는 기본적으로 최대 272K를 사용하며 여기서 1M으로 변경할 수 있습니다. Codex는 창과 자동 압축 임계값을 함께 적용하고 5%의 여유를 유지합니다. 실제 용량은 모델 서비스에 따라 다릅니다.", + "thinking": "사고", + "thinkingOverride": { + "label": "사고 등급 선언", + "inherit": "제공자 따르기", + "declared": "선언됨", + "sourceHint": "카탈로그에 사고 등급이 없습니다. 모델이 실제로 추론한다면 여기에서 선언하세요", + "saveFailed": "사고 선언을 저장하지 못했습니다", + "saveFailedWithReason": "사고 선언을 저장하지 못했습니다: {{reason}}" + }, + "thinkingCapability": { + "on": "사고 지원", + "off": "사고 미지원" + } }, "kindFilter": { "all": "전체", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index aaf35373ed2..5ec911b940b 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -1229,9 +1229,10 @@ "MODEL_SWITCH_TASK_RUNNING": "模型未切换:当前任务仍在运行。请等待本次回复结束后再试", "MODEL_WINDOW_PREPARATION_IN_PROGRESS": "模型未切换:正在整理当前任务的上下文。请等待完成后再试", "SESSION_RUNNING": "当前任务正在运行,请稍后再试。", - "PI_IMAGE_INPUT_UNSUPPORTED": "当前 Pi 模型未启用图片输入。请切换到支持图片的模型;如果这是自定义模型,请在供应商设置中开启“支持图片输入”,然后新建 Pi 任务。", + "PI_IMAGE_INPUT_UNSUPPORTED": "当前 Pi 模型未启用图片输入。请切换到支持图片的模型;如果这是自定义模型,请在供应商设置中开启“支持图片输入”,然后切换一次模型或新建 Pi 任务。", "CREDENTIAL_SWITCH_BUSY": "Codex 正在其它任务中运行,本次来源切换未生效(本任务仍使用原来源)。等它结束后再试。", - "PI_CATALOG_RELOAD_UNCONFIRMED": "模型目录重载未确认,已终止本任务。请重新打开任务后再切换模型。", + "PI_CATALOG_RELOAD_UNCONFIRMED": "模型目录重载未确认,已终止本任务。请重新打开任务后重试。", + "PI_IMAGE_CAPABILITY_REFRESH_FAILED": "图片输入能力更新未生效(已回滚或稍后自动重试)。请重试;若持续失败请重新打开任务。", "REMOTE_MODEL_SWITCH_ROUTE_CHANGE": "目标模型或来源需要不同的远端路由,请关闭并重新创建远程任务后生效。", "REMOTE_PROVIDER_UPDATING": "供应商凭证正在更新,请稍后重试", "REMOTE_PROVIDER_UNSUPPORTED": "该供应商不支持 SSH 远程会话,请更换来源", @@ -3730,6 +3731,14 @@ }, "capabilityHint": "优先使用当前供应商声明的能力;未声明时,图片能力参考模型名称。", "imageInput": "图片输入", + "imageInputOverride": { + "label": "图片输入能力声明", + "inherit": "跟随供应商", + "declaredTrue": "支持图片输入", + "declaredFalse": "不支持图片输入", + "saveFailed": "保存图片输入能力失败", + "saveFailedWithReason": "保存图片输入能力失败:{{message}}" + }, "supported": "支持", "unsupported": "不支持", "undeclared": "未声明", @@ -3805,7 +3814,20 @@ }, "codexContextUnknown": "未读取到 CLI 配置", "codexContextValues": "总窗口 {{total}} · 可用 {{usable}} · 自动压缩 {{compact}} tokens", - "contextLimitGptHint": "GPT 默认最多使用 272K;可在此自定义到 1M。Codex 会同步应用窗口与自动压缩阈值,保留 5% 余量;实际容量受模型服务限制。" + "contextLimitGptHint": "GPT 默认最多使用 272K;可在此自定义到 1M。Codex 会同步应用窗口与自动压缩阈值,保留 5% 余量;实际容量受模型服务限制。", + "thinking": "思考", + "thinkingOverride": { + "label": "声明思考档位", + "inherit": "跟随供应商", + "declared": "已声明", + "sourceHint": "目录没有声明思考档位;若该模型实际会推理,可在此声明", + "saveFailed": "保存思考声明失败", + "saveFailedWithReason": "保存思考声明失败:{{reason}}" + }, + "thinkingCapability": { + "on": "支持思考", + "off": "不支持思考" + } }, "kindFilter": { "all": "全部", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index 565e46a6aad..cd6350f3c3a 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -1229,9 +1229,10 @@ "MODEL_SWITCH_TASK_RUNNING": "模型未切換:目前任務仍在執行。請等待本次回覆結束後再試", "MODEL_WINDOW_PREPARATION_IN_PROGRESS": "模型未切換:正在整理目前任務的上下文。請等待完成後再試", "SESSION_RUNNING": "當前任務正在執行,請稍後再試。", - "PI_IMAGE_INPUT_UNSUPPORTED": "當前 Pi 模型未啟用圖片輸入。請切換到支援圖片的模型;如果這是自定義模型,請在供應商設定中開啟“支援圖片輸入”,然後新建 Pi 任務。", + "PI_IMAGE_INPUT_UNSUPPORTED": "當前 Pi 模型未啟用圖片輸入。請切換到支援圖片的模型;如果這是自定義模型,請在供應商設定中開啟“支援圖片輸入”,然後切換一次模型或新建 Pi 任務。", "CREDENTIAL_SWITCH_BUSY": "Codex 正在其它任務中執行,本次來源切換未生效(本任務仍使用原來源)。等它結束後再試。", - "PI_CATALOG_RELOAD_UNCONFIRMED": "模型目錄重載未確認,已終止本任務。請重新開啟任務後再切換模型。", + "PI_CATALOG_RELOAD_UNCONFIRMED": "模型目錄重載未確認,已終止本任務。請重新開啟任務後重試。", + "PI_IMAGE_CAPABILITY_REFRESH_FAILED": "圖片輸入能力更新未生效(已回滾或稍後自動重試)。請重試;若持續失敗請重新開啟任務。", "REMOTE_MODEL_SWITCH_ROUTE_CHANGE": "目標模型或來源需要不同的遠端路由,請關閉並重新建立遠端任務後生效。", "REMOTE_PROVIDER_UPDATING": "供應商憑證正在更新,請稍後重試", "REMOTE_PROVIDER_UNSUPPORTED": "該供應商不支援 SSH 遠端會話,請更換來源", @@ -3730,6 +3731,14 @@ }, "capabilityHint": "優先使用目前供應商宣告的能力;未宣告時,圖片能力參考模型名稱。", "imageInput": "圖片輸入", + "imageInputOverride": { + "label": "圖片輸入能力宣告", + "inherit": "跟隨供應商", + "declaredTrue": "支援圖片輸入", + "declaredFalse": "不支援圖片輸入", + "saveFailed": "儲存圖片輸入能力失敗", + "saveFailedWithReason": "儲存圖片輸入能力失敗:{{message}}" + }, "supported": "支援", "unsupported": "不支援", "undeclared": "未宣告", @@ -3805,7 +3814,20 @@ }, "codexContextUnknown": "未讀取到 CLI 設定", "codexContextValues": "總視窗 {{total}} · 可用 {{usable}} · 自動壓縮 {{compact}} tokens", - "contextLimitGptHint": "GPT 預設最多使用 272K;可在此自訂為 1M。Codex 會同步套用視窗與自動壓縮門檻,保留 5% 餘量;實際容量受模型服務限制。" + "contextLimitGptHint": "GPT 預設最多使用 272K;可在此自訂為 1M。Codex 會同步套用視窗與自動壓縮門檻,保留 5% 餘量;實際容量受模型服務限制。", + "thinking": "思考", + "thinkingOverride": { + "label": "宣告思考檔位", + "inherit": "跟隨供應商", + "declared": "已宣告", + "sourceHint": "目錄沒有宣告思考檔位;若該模型實際會推理,可在此宣告", + "saveFailed": "儲存思考宣告失敗", + "saveFailedWithReason": "儲存思考宣告失敗:{{reason}}" + }, + "thinkingCapability": { + "on": "支援思考", + "off": "不支援思考" + } }, "kindFilter": { "all": "全部", diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index 51ff58a86b1..0c112254aaf 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -6003,6 +6003,21 @@ interface ElectronAPI { limit: number | null, owner: import('../shared/modelContextLimit').ModelContextLimitOwner, ) => Promise; + getModelCatalogThinking: ( + target: import('../shared/modelCatalogThinking').ModelCatalogThinkingTarget, + ) => Promise; + setModelCatalogThinking: ( + target: import('../shared/modelCatalogThinking').ModelCatalogThinkingTarget, + tiers: string[] | null, + defaultTier?: string | null, + ) => Promise; + getModelCatalogImageInput: ( + target: import('../shared/modelCatalogImageInput').ModelCatalogImageInputTarget, + ) => Promise; + setModelCatalogImageInput: ( + target: import('../shared/modelCatalogImageInput').ModelCatalogImageInputTarget, + value: boolean | null, + ) => Promise; resetModelContextLimit: ( target: import('../shared/modelContextLimit').ModelContextLimitTarget, owner: import('../shared/modelContextLimit').ModelContextLimitOwner, diff --git a/apps/desktop/src/shared/__tests__/inputError.test.ts b/apps/desktop/src/shared/__tests__/inputError.test.ts index 50dbc94836e..9c877fcbb81 100644 --- a/apps/desktop/src/shared/__tests__/inputError.test.ts +++ b/apps/desktop/src/shared/__tests__/inputError.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { PI_IMAGE_INPUT_UNSUPPORTED_MARKER, isPiImageInputUnsupportedError } from '../inputError'; +import { + PI_IMAGE_CAPABILITY_REFRESH_FAILED_MARKER, + PI_IMAGE_INPUT_UNSUPPORTED_MARKER, + isPiImageCapabilityRefreshFailedError, + isPiImageInputUnsupportedError, +} from '../inputError'; describe('isPiImageInputUnsupportedError', () => { it('recognizes both the structured code and the wire-safe marker', () => { @@ -20,3 +25,26 @@ describe('isPiImageInputUnsupportedError', () => { ); }); }); + +describe('isPiImageCapabilityRefreshFailedError', () => { + it('recognizes both the structured code and the wire-safe marker', () => { + expect( + isPiImageCapabilityRefreshFailedError( + Object.assign(new Error('pending'), { code: 'PI_IMAGE_CAPABILITY_REFRESH_FAILED' }), + ), + ).toBe(true); + expect( + isPiImageCapabilityRefreshFailedError(`${PI_IMAGE_CAPABILITY_REFRESH_FAILED_MARKER} pending`), + ).toBe(true); + }); + + it('does not confuse the retryable refresh failure with the unsupported verdict', () => { + // 两个码在 UI 上是两种处置:前者可重试,后者要用户换模型/开能力。混判会给出错误指引。 + expect( + isPiImageCapabilityRefreshFailedError(`${PI_IMAGE_INPUT_UNSUPPORTED_MARKER} disabled`), + ).toBe(false); + expect( + isPiImageInputUnsupportedError(`${PI_IMAGE_CAPABILITY_REFRESH_FAILED_MARKER} pending`), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/shared/inputError.ts b/apps/desktop/src/shared/inputError.ts index cc9671adfdc..48a76e0d011 100644 --- a/apps/desktop/src/shared/inputError.ts +++ b/apps/desktop/src/shared/inputError.ts @@ -1,5 +1,7 @@ export const PI_IMAGE_INPUT_UNSUPPORTED_CODE = 'PI_IMAGE_INPUT_UNSUPPORTED'; export const PI_IMAGE_INPUT_UNSUPPORTED_MARKER = `[${PI_IMAGE_INPUT_UNSUPPORTED_CODE}]`; +export const PI_IMAGE_CAPABILITY_REFRESH_FAILED_CODE = 'PI_IMAGE_CAPABILITY_REFRESH_FAILED'; +export const PI_IMAGE_CAPABILITY_REFRESH_FAILED_MARKER = `[${PI_IMAGE_CAPABILITY_REFRESH_FAILED_CODE}]`; /** Stable Desktop main -> renderer/device-link marker; presentation localizes at display time. */ export function isPiImageInputUnsupportedError(value: unknown): boolean { @@ -13,6 +15,19 @@ export function isPiImageInputUnsupportedError(value: unknown): boolean { ); } +/** 图片能力已改但子进程还没加载(回合在跑 / 重载被拒),可重试。 */ +export function isPiImageCapabilityRefreshFailedError(value: unknown): boolean { + const message = value instanceof Error ? value.message : String(value ?? ''); + const code = + value && typeof value === 'object' && !Array.isArray(value) + ? (value as { code?: unknown }).code + : null; + return ( + code === PI_IMAGE_CAPABILITY_REFRESH_FAILED_CODE || + message.includes(PI_IMAGE_CAPABILITY_REFRESH_FAILED_MARKER) + ); +} + /** The existing Pi prompt RPC expiry signal, shared by native recovery and group settlement. */ export function isPiPromptRpcTimeoutError(data: unknown): boolean { if (!data || typeof data !== 'object') return false; diff --git a/apps/desktop/src/shared/ipc-errors.ts b/apps/desktop/src/shared/ipc-errors.ts index 172be8209a2..71b031feaaf 100644 --- a/apps/desktop/src/shared/ipc-errors.ts +++ b/apps/desktop/src/shared/ipc-errors.ts @@ -133,6 +133,8 @@ export type IpcErrorCode = | 'CREDENTIAL_SWITCH_BUSY' // Pi 热切 SuperGrok 时 switch_session 未确认,任务已终止。 | 'PI_CATALOG_RELOAD_UNCONFIRMED' + // Pi 图片输入能力对账失败:写 models.json / 子进程重载被拒。会话仍可用(已回滚或保留 pending)。 + | 'PI_IMAGE_CAPABILITY_REFRESH_FAILED' // 远端 Claude 路由 materialization 失败(remote-claude-route.ts): // 供应商凭证 mutation 窗口(稍后重试)/ 远端不可表达(换来源)/ 订阅未连接(连接 Claude.ai)。 | 'REMOTE_PROVIDER_UPDATING' @@ -395,6 +397,7 @@ const IPC_ERROR_CODES: ReadonlySet = new Set([ 'SESSION_RUNNING', 'CREDENTIAL_SWITCH_BUSY', 'PI_CATALOG_RELOAD_UNCONFIRMED', + 'PI_IMAGE_CAPABILITY_REFRESH_FAILED', 'REMOTE_PROVIDER_UPDATING', 'REMOTE_PROVIDER_UNSUPPORTED', 'REMOTE_NATIVE_OAUTH_UNAVAILABLE', diff --git a/apps/desktop/src/shared/modelCatalogImageInput.ts b/apps/desktop/src/shared/modelCatalogImageInput.ts new file mode 100644 index 00000000000..7cb53e613f7 --- /dev/null +++ b/apps/desktop/src/shared/modelCatalogImageInput.ts @@ -0,0 +1,29 @@ +import type { ModelPriceOverrideTarget } from './modelPriceOverride'; + +/** + * 单模型图片输入能力的本地目录 override(设置 → 模型 → 高级设置)。 + * + * 与价格/上下文上限 override 共用 (providerId, agent, modelId) 目标形状;agent 只用于 + * 目录成员校验,实际写入的是**模型级** base patch(图片能力是公共字段,见 + * docs/product-rules/model-metadata-precedence.md)。 + */ +export interface ModelCatalogImageInputTarget extends ModelPriceOverrideTarget { + /** + * 同一行在**其它引擎**下的 wire id。桥接投影两端 id 不同(例如 openai 行:codex 用 + * `gpt-5.6-sol`、pi 用 `chatgpt/gpt-5.6-sol`),而本机 override 按 (providerId, modelId) + * 精确匹配 —— 只写主展示引擎的 id,运行期真正消费该能力的 Pi 读不到,UI 却会显示已声明。 + */ + relatedTargets?: ModelPriceOverrideTarget[]; +} + +/** 读回视图。value=null 表示没有本地声明(跟随供应商)。 */ +export interface ModelCatalogImageInputView { + value: boolean | null; + isCustomized: boolean; + /** + * 该行各引擎的声明**不一致**(手工改文件/旧版单键写入留下的存量数据)。展示值取运行期 + * 真正消费该能力的引擎(Pi)那一侧,但 UI 必须允许用户“重选当前项”把分裂写回一致 —— + * 否则同值 no-op 会让分叉永远修不掉。 + */ + diverged?: boolean; +} diff --git a/apps/desktop/src/shared/modelCatalogThinking.ts b/apps/desktop/src/shared/modelCatalogThinking.ts new file mode 100644 index 00000000000..2be536a3a67 --- /dev/null +++ b/apps/desktop/src/shared/modelCatalogThinking.ts @@ -0,0 +1,42 @@ +import type { ModelPriceOverrideTarget } from './modelPriceOverride'; + +/** + * 单模型思考档位的本地目录 override(设置 → 模型 → 高级设置)。 + * + * 与价格 / 上下文上限 / 图片输入 override 共用 `(providerId, agent, modelId)` 目标形状。 + * 写入的是模型级 base patch 的 `efforts` + `defaultEffort`(思考档位是公共字段,见 + * `docs/product-rules/model-metadata-precedence.md`)。 + * + * 为什么需要它:目录里的思考档位决定 Pi 能不能开真正的 thinking 通道 + * (`piThinkingLevels.mjs`:`reasoning !== true` → 零档位)。上游把一个**会推理**的模型 + * 声明成「不支持思考」时,Pi 没有通道可用,模型推理只能随 content 返回、被 Cindy 当 + * **普通 assistant 正文**渲染(实报:协同 worker 切到该模型后正文变成推理叙述)。 + * 这是「未声明」与「声明错误」两种真实状态,用户必须能自己显式声明。 + */ +export interface ModelCatalogThinkingTarget extends ModelPriceOverrideTarget { + /** + * 同一行在**其它引擎**下的 wire id。桥接投影两端 id 不同(例如 openai 行:codex 用 + * `gpt-5.6-sol`、pi 用 `chatgpt/gpt-5.6-sol`),而本机 override 按 (providerId, modelId) + * 精确匹配 —— 只写主展示引擎的 id,运行期真正消费档位的 Pi 读不到,UI 却显示已声明。 + */ + relatedTargets?: ModelPriceOverrideTarget[]; +} + +/** 读回视图。value=null 表示没有本地声明(跟随供应商)。 */ +export interface ModelCatalogThinkingView { + /** null = 没有本地声明(跟随供应商实报)。 */ + value: string[] | null; + /** + * 本机声明里的默认档。`value` 非 null 时才有意义。 + * 必须读回:否则用户在抽屉里改档位时无从知道当前默认是哪一档,写入侧只能猜一个 + * (例如「排序第一档」),那会把厂商声明的默认静默改掉且用户无从修正。 + */ + defaultEffort?: string | null; + isCustomized: boolean; + /** + * 该行各引擎的声明**不一致**(手工改文件 / 旧版单键写入留下的存量数据)。展示值取运行期 + * 真正消费档位的引擎(Pi)那一侧,但 UI 必须允许用户「重选当前项」把分裂写回一致 —— + * 否则同值 no-op 会让分叉永远修不掉。 + */ + diverged?: boolean; +} diff --git a/docs/design-rules/design-inventory.md b/docs/design-rules/design-inventory.md index 857a86c945e..7c8ae8db879 100644 --- a/docs/design-rules/design-inventory.md +++ b/docs/design-rules/design-inventory.md @@ -38,7 +38,7 @@ | `desktop.overlay.toast` | desktop | Toast | App 常驻 ToastContainer(用户可见出口,不展开业务逻辑) | Toast, ToastContainer | apps/desktop/src/renderer/components/ui/toast/DownloadToast.tsx, apps/desktop/src/renderer/components/ui/toast/Toast.tsx, apps/desktop/src/renderer/components/ui/toast/ToastContainer.tsx, apps/desktop/src/renderer/components/ui/toast/index.ts | 7 | 4 | 3 | | `desktop.plugins.app-main` | desktop | 插件主视图 | hash `/apps/:ghostId`(GhostMainViewFeatureLayout) | GhostMainViewFeatureLayout, GhostMainViewHost | apps/desktop/src/renderer/features/plugin/GhostMainViewFeatureLayout.tsx, apps/desktop/src/renderer/features/plugin/GhostMainViewHost.tsx | 0 | 0 | 0 | | `desktop.plugins.installed` | desktop | 已装插件 | hash `/plugins`(GhostPluginPage) | AddMarketplaceDialog, GhostPluginActions, GhostPluginDetailView, GhostPluginPage, MarketPluginDetailView, MyPublishesSection, PluginDevicePicker, PluginManagementLayout, PluginProjectMenu, RemotePluginConfiguration, RemotePluginPage, RemotePluginReadOnlyDetail, UpdateAllDialog | apps/desktop/src/renderer/features/plugin/AddMarketplaceDialog.tsx, apps/desktop/src/renderer/features/plugin/GhostPluginActions.tsx, apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx, apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx, apps/desktop/src/renderer/features/plugin/MarketPluginDetailView.tsx, apps/desktop/src/renderer/features/plugin/MyPublishesSection.tsx, apps/desktop/src/renderer/features/plugin/PluginDevicePicker.tsx, apps/desktop/src/renderer/features/plugin/PluginManagementLayout.tsx, apps/desktop/src/renderer/features/plugin/PluginScopePicker.tsx, apps/desktop/src/renderer/features/plugin/RemotePluginConfiguration.tsx, apps/desktop/src/renderer/features/plugin/RemotePluginPage.tsx, apps/desktop/src/renderer/features/plugin/RemotePluginReadOnlyDetail.tsx, apps/desktop/src/renderer/features/plugin/UpdateAllDialog.tsx, apps/desktop/src/renderer/features/plugin/plugin-motion.css | 28 | 0 | 48 | -| `desktop.settings` | desktop | 设置 | hash `/settings`(SettingsView;tab 含 general / personalization / providers / billing / usage / voice-input / im-bot / shortcuts / agent-island / import / remote-control / ghosts / builtin-tools / computer-use / help / about) | SettingsView | apps/desktop/src/renderer/components/settings/AboutSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AboutSection.tsx, apps/desktop/src/renderer/components/settings/AccountDeletionSection.tsx, apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx, apps/desktop/src/renderer/components/settings/AddSpeechModelsDialog.tsx, apps/desktop/src/renderer/components/settings/AgentIslandSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AgentIslandSection.tsx, apps/desktop/src/renderer/components/settings/AgentResourceSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx, apps/desktop/src/renderer/components/settings/AppearanceSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AppearanceSection.tsx, apps/desktop/src/renderer/components/settings/AuxiliaryModelSection.tsx, apps/desktop/src/renderer/components/settings/AvatarCropEditor.tsx, apps/desktop/src/renderer/components/settings/BetaChannelCell.tsx, apps/desktop/src/renderer/components/settings/BillingSettings.settings-search.ts, apps/desktop/src/renderer/components/settings/BrowserBackendSubsection.tsx, apps/desktop/src/renderer/components/settings/BrowserRealProfileSubsection.tsx, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.settings-search.ts, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.tsx, apps/desktop/src/renderer/components/settings/ChatEmbeddingCell.tsx, apps/desktop/src/renderer/components/settings/ChipMetricsSection.tsx, apps/desktop/src/renderer/components/settings/CindyMakeSection.settings-search.ts, apps/desktop/src/renderer/components/settings/CindyMakeSection.tsx, apps/desktop/src/renderer/components/settings/CollaborationSection.settings-search.ts, apps/desktop/src/renderer/components/settings/CollaborationSection.tsx, apps/desktop/src/renderer/components/settings/CompactionSection.tsx, apps/desktop/src/renderer/components/settings/ComposerSendShortcutSection.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionGuideWindow.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionRow.tsx, apps/desktop/src/renderer/components/settings/ComputerUseSection.settings-search.ts, apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx, apps/desktop/src/renderer/components/settings/CustomProviderRuntimeFillOverlay.tsx, apps/desktop/src/renderer/components/settings/DefaultOverrideControls.tsx, apps/desktop/src/renderer/components/settings/DialogueDirectorySection.tsx, apps/desktop/src/renderer/components/settings/DingTalkBotSection.tsx, apps/desktop/src/renderer/components/settings/DiscordBotSection.tsx, apps/desktop/src/renderer/components/settings/DownloadMeter.tsx, apps/desktop/src/renderer/components/settings/ExperimentalSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotNotificationSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotSection.tsx, apps/desktop/src/renderer/components/settings/FontFamilyPicker.tsx, apps/desktop/src/renderer/components/settings/GeneralSettings.settings-search.ts, apps/desktop/src/renderer/components/settings/GenericGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/GitSafetySection.settings-search.ts, apps/desktop/src/renderer/components/settings/GitSafetySection.tsx, apps/desktop/src/renderer/components/settings/HarnessVersionMenuRow.tsx, apps/desktop/src/renderer/components/settings/HelpAssistantPanel.tsx, apps/desktop/src/renderer/components/settings/HelpSection.settings-search.ts, apps/desktop/src/renderer/components/settings/HelpSection.tsx, apps/desktop/src/renderer/components/settings/HelpThreadView.tsx, apps/desktop/src/renderer/components/settings/HookConnectionsSection.tsx, apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx, apps/desktop/src/renderer/components/settings/ImBotSection.settings-search.ts, apps/desktop/src/renderer/components/settings/ImBotSection.tsx, apps/desktop/src/renderer/components/settings/ImChannelSettingsCard.tsx, apps/desktop/src/renderer/components/settings/ImConnectionRecovery.tsx, apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx, apps/desktop/src/renderer/components/settings/ImLifecycleAnnouncementSection.tsx, apps/desktop/src/renderer/components/settings/InputDeviceConnectionStatus.tsx, apps/desktop/src/renderer/components/settings/JoyConGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.settings-search.ts, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.tsx, apps/desktop/src/renderer/components/settings/LanguageSection.tsx, apps/desktop/src/renderer/components/settings/LayoutResetControl.tsx, apps/desktop/src/renderer/components/settings/LinkOpenSection.tsx, apps/desktop/src/renderer/components/settings/LlamaCppCandidates.tsx, apps/desktop/src/renderer/components/settings/LlamaCppProviderDetail.tsx, apps/desktop/src/renderer/components/settings/LocalAgentSection.settings-search.ts, apps/desktop/src/renderer/components/settings/LocalAgentSection.tsx, apps/desktop/src/renderer/components/settings/LocalModelDownloadUI.tsx, apps/desktop/src/renderer/components/settings/LocalOllamaInstall.tsx, apps/desktop/src/renderer/components/settings/LocalPackagingTag.tsx, apps/desktop/src/renderer/components/settings/LogoutSection.tsx, apps/desktop/src/renderer/components/settings/LspBetaCell.tsx, apps/desktop/src/renderer/components/settings/McpServerDialog.tsx, apps/desktop/src/renderer/components/settings/McpServersSection.tsx, apps/desktop/src/renderer/components/settings/MemorySection.tsx, apps/desktop/src/renderer/components/settings/MessageNavRailCell.tsx, apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx, apps/desktop/src/renderer/components/settings/ModelCompactionSettings.tsx, apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx, apps/desktop/src/renderer/components/settings/MyDevicesPanel.tsx, apps/desktop/src/renderer/components/settings/NotificationSection.settings-search.ts, apps/desktop/src/renderer/components/settings/NotificationSection.tsx, apps/desktop/src/renderer/components/settings/OAuthDeviceCodeCard.tsx, apps/desktop/src/renderer/components/settings/OllamaProviderDetail.tsx, apps/desktop/src/renderer/components/settings/OwnRemoteProviders.tsx, apps/desktop/src/renderer/components/settings/PersonalizationDetails.settings-search.ts, apps/desktop/src/renderer/components/settings/PersonalizationSettings.settings-search.ts, apps/desktop/src/renderer/components/settings/PiKernelVersionRow.tsx, apps/desktop/src/renderer/components/settings/PiPackagesSection.tsx, apps/desktop/src/renderer/components/settings/PlayStationGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/ProfileEditDialog.tsx, apps/desktop/src/renderer/components/settings/ProviderAccountUsageModule.tsx, apps/desktop/src/renderer/components/settings/ProviderConnectionDialog.tsx, apps/desktop/src/renderer/components/settings/ProviderImportDialog.tsx, apps/desktop/src/renderer/components/settings/ProvidersSection.settings-search.ts, apps/desktop/src/renderer/components/settings/ProvidersSection.tsx, apps/desktop/src/renderer/components/settings/ReadOnlyProviderModels.tsx, apps/desktop/src/renderer/components/settings/RemoteControlSection.settings-search.ts, apps/desktop/src/renderer/components/settings/RemoteControlSection.tsx, apps/desktop/src/renderer/components/settings/RemoteDesktopPermissions.tsx, apps/desktop/src/renderer/components/settings/RemoteDesktopSetting.tsx, apps/desktop/src/renderer/components/settings/RemoteHostDetail.tsx, apps/desktop/src/renderer/components/settings/RemoteSection.tsx, apps/desktop/src/renderer/components/settings/SessionImportSection.settings-search.ts, apps/desktop/src/renderer/components/settings/SessionImportSection.tsx, apps/desktop/src/renderer/components/settings/SessionRuntimeFallbackCell.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.settings-search.ts, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.tsx, apps/desktop/src/renderer/components/settings/SettingsSearchBox.tsx, apps/desktop/src/renderer/components/settings/SettingsSearchNavigation.ts, apps/desktop/src/renderer/components/settings/SettingsSidebarNav.tsx, apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx, apps/desktop/src/renderer/components/settings/SettingsView.tsx, apps/desktop/src/renderer/components/settings/SharedTasks.settings-search.ts, apps/desktop/src/renderer/components/settings/SilentEncryptedRetryCell.tsx, apps/desktop/src/renderer/components/settings/SpeechModelChoices.tsx, apps/desktop/src/renderer/components/settings/SshKeySetupDialog.tsx, apps/desktop/src/renderer/components/settings/StorageManagementCard.settings-search.ts, apps/desktop/src/renderer/components/settings/StorageManagementCard.tsx, apps/desktop/src/renderer/components/settings/StreamFadeSection.tsx, apps/desktop/src/renderer/components/settings/SubagentModelSection.settings-search.ts, apps/desktop/src/renderer/components/settings/SubagentModelSection.tsx, apps/desktop/src/renderer/components/settings/SwitchProGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/TelegramBehaviorSettings.tsx, apps/desktop/src/renderer/components/settings/TelegramBotSection.tsx, apps/desktop/src/renderer/components/settings/TelegramRemoteDevices.tsx, apps/desktop/src/renderer/components/settings/TerminalShellSection.tsx, apps/desktop/src/renderer/components/settings/TipsSection.settings-search.ts, apps/desktop/src/renderer/components/settings/TipsSection.tsx, apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx, apps/desktop/src/renderer/components/settings/UserProfileCard.tsx, apps/desktop/src/renderer/components/settings/UserPromptSection.tsx, apps/desktop/src/renderer/components/settings/VisionBridgeSection.settings-search.ts, apps/desktop/src/renderer/components/settings/VisionBridgeSection.tsx, apps/desktop/src/renderer/components/settings/VoiceInputSection.settings-search.ts, apps/desktop/src/renderer/components/settings/VoiceInputSection.tsx, apps/desktop/src/renderer/components/settings/VoiceSpeakerEnrollmentDialog.tsx, apps/desktop/src/renderer/components/settings/VoiceSpeakerFilterCard.tsx, apps/desktop/src/renderer/components/settings/WallpaperSection.tsx, apps/desktop/src/renderer/components/settings/WechatBotSection.tsx, apps/desktop/src/renderer/components/settings/WecomBotSection.tsx, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.settings-search.ts, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeyboardLayout.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeycapGlyphs.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexSettings.tsx, apps/desktop/src/renderer/components/settings/WorktreeRecycleCard.settings-search.ts, apps/desktop/src/renderer/components/settings/WorktreeRecycleCard.tsx, apps/desktop/src/renderer/components/settings/XUsageGuide.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadSettings.tsx, apps/desktop/src/renderer/components/settings/androidStatusPresentation.ts, apps/desktop/src/renderer/components/settings/avatarCropGeometry.ts, apps/desktop/src/renderer/components/settings/avatarCropImage.ts, apps/desktop/src/renderer/components/settings/billingVisibility.ts, apps/desktop/src/renderer/components/settings/browserOpenForLoginError.ts, apps/desktop/src/renderer/components/settings/cindyMakeVisibility.ts, apps/desktop/src/renderer/components/settings/computerPermissionFlow.ts, apps/desktop/src/renderer/components/settings/contacts/ContactDetailPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsImportDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsListPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsSection.tsx, apps/desktop/src/renderer/components/settings/contacts/startContactsAiSession.ts, apps/desktop/src/renderer/components/settings/dialogueDirectoryDisplay.ts, apps/desktop/src/renderer/components/settings/dualSenseSilhouette.ts, apps/desktop/src/renderer/components/settings/feishuBotPresentation.ts, apps/desktop/src/renderer/components/settings/fontFamilyValue.ts, apps/desktop/src/renderer/components/settings/gamepadLayoutPrimitives.tsx, apps/desktop/src/renderer/components/settings/gamepadSilhouetteGeom.ts, apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts, apps/desktop/src/renderer/components/settings/imBotVisibility.ts, apps/desktop/src/renderer/components/settings/imDefaultSettingsLogic.ts, apps/desktop/src/renderer/components/settings/joyConSilhouette.ts, apps/desktop/src/renderer/components/settings/modelManagementPresentation.ts, apps/desktop/src/renderer/components/settings/modelManagementState.ts, apps/desktop/src/renderer/components/settings/myDevicesModel.ts, apps/desktop/src/renderer/components/settings/providerAssetModule.ts, apps/desktop/src/renderer/components/settings/realProfilePermissionGuide.ts, apps/desktop/src/renderer/components/settings/settingsRowClasses.ts, apps/desktop/src/renderer/components/settings/settingsSearchCatalog.ts, apps/desktop/src/renderer/components/settings/settingsSearchTypes.ts, apps/desktop/src/renderer/components/settings/switchProSilhouette.ts, apps/desktop/src/renderer/components/settings/ultimateC1Silhouette.ts, apps/desktop/src/renderer/components/settings/usage/UsageBarsTooltip.tsx, apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx, apps/desktop/src/renderer/components/settings/usage/UsageDeviceSelect.tsx, apps/desktop/src/renderer/components/settings/usage/UsageFilterSelect.tsx, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.settings-search.ts, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx, apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx, apps/desktop/src/renderer/components/settings/usage/formatUsagePercent.ts, apps/desktop/src/renderer/components/settings/usage/usageCharts.css, apps/desktop/src/renderer/components/settings/usage/usageHistoryColors.ts, apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts, apps/desktop/src/renderer/components/settings/usageVisibility.ts, apps/desktop/src/renderer/components/settings/useAutomationSettingsRead.ts, apps/desktop/src/renderer/components/settings/useProviderSubscriptionCard.ts, apps/desktop/src/renderer/components/settings/useSettingsEscape.ts, apps/desktop/src/renderer/components/settings/useSettingsSectionTarget.ts, apps/desktop/src/renderer/components/settings/wizardRecommend.ts, apps/desktop/src/renderer/components/settings/workLouderCodexCommandCopy.ts, apps/desktop/src/renderer/components/settings/xboxSeriesSilhouette.ts, apps/desktop/src/renderer/styles/sortable.css | 137 | 95 | 552 | +| `desktop.settings` | desktop | 设置 | hash `/settings`(SettingsView;tab 含 general / personalization / providers / billing / usage / voice-input / im-bot / shortcuts / agent-island / import / remote-control / ghosts / builtin-tools / computer-use / help / about) | SettingsView | apps/desktop/src/renderer/components/settings/AboutSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AboutSection.tsx, apps/desktop/src/renderer/components/settings/AccountDeletionSection.tsx, apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx, apps/desktop/src/renderer/components/settings/AddSpeechModelsDialog.tsx, apps/desktop/src/renderer/components/settings/AgentIslandSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AgentIslandSection.tsx, apps/desktop/src/renderer/components/settings/AgentResourceSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx, apps/desktop/src/renderer/components/settings/AppearanceSection.settings-search.ts, apps/desktop/src/renderer/components/settings/AppearanceSection.tsx, apps/desktop/src/renderer/components/settings/AuxiliaryModelSection.tsx, apps/desktop/src/renderer/components/settings/AvatarCropEditor.tsx, apps/desktop/src/renderer/components/settings/BetaChannelCell.tsx, apps/desktop/src/renderer/components/settings/BillingSettings.settings-search.ts, apps/desktop/src/renderer/components/settings/BrowserBackendSubsection.tsx, apps/desktop/src/renderer/components/settings/BrowserRealProfileSubsection.tsx, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.settings-search.ts, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.tsx, apps/desktop/src/renderer/components/settings/ChatEmbeddingCell.tsx, apps/desktop/src/renderer/components/settings/ChipMetricsSection.tsx, apps/desktop/src/renderer/components/settings/CindyMakeSection.settings-search.ts, apps/desktop/src/renderer/components/settings/CindyMakeSection.tsx, apps/desktop/src/renderer/components/settings/CollaborationSection.settings-search.ts, apps/desktop/src/renderer/components/settings/CollaborationSection.tsx, apps/desktop/src/renderer/components/settings/CompactionSection.tsx, apps/desktop/src/renderer/components/settings/ComposerSendShortcutSection.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionGuideWindow.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionRow.tsx, apps/desktop/src/renderer/components/settings/ComputerUseSection.settings-search.ts, apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx, apps/desktop/src/renderer/components/settings/CustomProviderRuntimeFillOverlay.tsx, apps/desktop/src/renderer/components/settings/DefaultOverrideControls.tsx, apps/desktop/src/renderer/components/settings/DialogueDirectorySection.tsx, apps/desktop/src/renderer/components/settings/DingTalkBotSection.tsx, apps/desktop/src/renderer/components/settings/DiscordBotSection.tsx, apps/desktop/src/renderer/components/settings/DownloadMeter.tsx, apps/desktop/src/renderer/components/settings/ExperimentalSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotNotificationSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotSection.tsx, apps/desktop/src/renderer/components/settings/FontFamilyPicker.tsx, apps/desktop/src/renderer/components/settings/GeneralSettings.settings-search.ts, apps/desktop/src/renderer/components/settings/GenericGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/GitSafetySection.settings-search.ts, apps/desktop/src/renderer/components/settings/GitSafetySection.tsx, apps/desktop/src/renderer/components/settings/HarnessVersionMenuRow.tsx, apps/desktop/src/renderer/components/settings/HelpAssistantPanel.tsx, apps/desktop/src/renderer/components/settings/HelpSection.settings-search.ts, apps/desktop/src/renderer/components/settings/HelpSection.tsx, apps/desktop/src/renderer/components/settings/HelpThreadView.tsx, apps/desktop/src/renderer/components/settings/HookConnectionsSection.tsx, apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx, apps/desktop/src/renderer/components/settings/ImBotSection.settings-search.ts, apps/desktop/src/renderer/components/settings/ImBotSection.tsx, apps/desktop/src/renderer/components/settings/ImChannelSettingsCard.tsx, apps/desktop/src/renderer/components/settings/ImConnectionRecovery.tsx, apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx, apps/desktop/src/renderer/components/settings/ImLifecycleAnnouncementSection.tsx, apps/desktop/src/renderer/components/settings/InputDeviceConnectionStatus.tsx, apps/desktop/src/renderer/components/settings/JoyConGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.settings-search.ts, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.tsx, apps/desktop/src/renderer/components/settings/LanguageSection.tsx, apps/desktop/src/renderer/components/settings/LayoutResetControl.tsx, apps/desktop/src/renderer/components/settings/LinkOpenSection.tsx, apps/desktop/src/renderer/components/settings/LlamaCppCandidates.tsx, apps/desktop/src/renderer/components/settings/LlamaCppProviderDetail.tsx, apps/desktop/src/renderer/components/settings/LocalAgentSection.settings-search.ts, apps/desktop/src/renderer/components/settings/LocalAgentSection.tsx, apps/desktop/src/renderer/components/settings/LocalModelDownloadUI.tsx, apps/desktop/src/renderer/components/settings/LocalOllamaInstall.tsx, apps/desktop/src/renderer/components/settings/LocalPackagingTag.tsx, apps/desktop/src/renderer/components/settings/LogoutSection.tsx, apps/desktop/src/renderer/components/settings/LspBetaCell.tsx, apps/desktop/src/renderer/components/settings/McpServerDialog.tsx, apps/desktop/src/renderer/components/settings/McpServersSection.tsx, apps/desktop/src/renderer/components/settings/MemorySection.tsx, apps/desktop/src/renderer/components/settings/MessageNavRailCell.tsx, apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx, apps/desktop/src/renderer/components/settings/ModelCompactionSettings.tsx, apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx, apps/desktop/src/renderer/components/settings/MyDevicesPanel.tsx, apps/desktop/src/renderer/components/settings/NotificationSection.settings-search.ts, apps/desktop/src/renderer/components/settings/NotificationSection.tsx, apps/desktop/src/renderer/components/settings/OAuthDeviceCodeCard.tsx, apps/desktop/src/renderer/components/settings/OllamaProviderDetail.tsx, apps/desktop/src/renderer/components/settings/OwnRemoteProviders.tsx, apps/desktop/src/renderer/components/settings/PersonalizationDetails.settings-search.ts, apps/desktop/src/renderer/components/settings/PersonalizationSettings.settings-search.ts, apps/desktop/src/renderer/components/settings/PiKernelVersionRow.tsx, apps/desktop/src/renderer/components/settings/PiPackagesSection.tsx, apps/desktop/src/renderer/components/settings/PlayStationGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/ProfileEditDialog.tsx, apps/desktop/src/renderer/components/settings/ProviderAccountUsageModule.tsx, apps/desktop/src/renderer/components/settings/ProviderConnectionDialog.tsx, apps/desktop/src/renderer/components/settings/ProviderImportDialog.tsx, apps/desktop/src/renderer/components/settings/ProvidersSection.settings-search.ts, apps/desktop/src/renderer/components/settings/ProvidersSection.tsx, apps/desktop/src/renderer/components/settings/ReadOnlyProviderModels.tsx, apps/desktop/src/renderer/components/settings/RemoteControlSection.settings-search.ts, apps/desktop/src/renderer/components/settings/RemoteControlSection.tsx, apps/desktop/src/renderer/components/settings/RemoteDesktopPermissions.tsx, apps/desktop/src/renderer/components/settings/RemoteDesktopSetting.tsx, apps/desktop/src/renderer/components/settings/RemoteHostDetail.tsx, apps/desktop/src/renderer/components/settings/RemoteSection.tsx, apps/desktop/src/renderer/components/settings/SessionImportSection.settings-search.ts, apps/desktop/src/renderer/components/settings/SessionImportSection.tsx, apps/desktop/src/renderer/components/settings/SessionRuntimeFallbackCell.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.settings-search.ts, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.tsx, apps/desktop/src/renderer/components/settings/SettingsSearchBox.tsx, apps/desktop/src/renderer/components/settings/SettingsSearchNavigation.ts, apps/desktop/src/renderer/components/settings/SettingsSidebarNav.tsx, apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx, apps/desktop/src/renderer/components/settings/SettingsView.tsx, apps/desktop/src/renderer/components/settings/SharedTasks.settings-search.ts, apps/desktop/src/renderer/components/settings/SilentEncryptedRetryCell.tsx, apps/desktop/src/renderer/components/settings/SpeechModelChoices.tsx, apps/desktop/src/renderer/components/settings/SshKeySetupDialog.tsx, apps/desktop/src/renderer/components/settings/StorageManagementCard.settings-search.ts, apps/desktop/src/renderer/components/settings/StorageManagementCard.tsx, apps/desktop/src/renderer/components/settings/StreamFadeSection.tsx, apps/desktop/src/renderer/components/settings/SubagentModelSection.settings-search.ts, apps/desktop/src/renderer/components/settings/SubagentModelSection.tsx, apps/desktop/src/renderer/components/settings/SwitchProGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/TelegramBehaviorSettings.tsx, apps/desktop/src/renderer/components/settings/TelegramBotSection.tsx, apps/desktop/src/renderer/components/settings/TelegramRemoteDevices.tsx, apps/desktop/src/renderer/components/settings/TerminalShellSection.tsx, apps/desktop/src/renderer/components/settings/TipsSection.settings-search.ts, apps/desktop/src/renderer/components/settings/TipsSection.tsx, apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx, apps/desktop/src/renderer/components/settings/UserProfileCard.tsx, apps/desktop/src/renderer/components/settings/UserPromptSection.tsx, apps/desktop/src/renderer/components/settings/VisionBridgeSection.settings-search.ts, apps/desktop/src/renderer/components/settings/VisionBridgeSection.tsx, apps/desktop/src/renderer/components/settings/VoiceInputSection.settings-search.ts, apps/desktop/src/renderer/components/settings/VoiceInputSection.tsx, apps/desktop/src/renderer/components/settings/VoiceSpeakerEnrollmentDialog.tsx, apps/desktop/src/renderer/components/settings/VoiceSpeakerFilterCard.tsx, apps/desktop/src/renderer/components/settings/WallpaperSection.tsx, apps/desktop/src/renderer/components/settings/WechatBotSection.tsx, apps/desktop/src/renderer/components/settings/WecomBotSection.tsx, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.settings-search.ts, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeyboardLayout.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeycapGlyphs.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexSettings.tsx, apps/desktop/src/renderer/components/settings/WorktreeRecycleCard.settings-search.ts, apps/desktop/src/renderer/components/settings/WorktreeRecycleCard.tsx, apps/desktop/src/renderer/components/settings/XUsageGuide.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadSettings.tsx, apps/desktop/src/renderer/components/settings/androidStatusPresentation.ts, apps/desktop/src/renderer/components/settings/avatarCropGeometry.ts, apps/desktop/src/renderer/components/settings/avatarCropImage.ts, apps/desktop/src/renderer/components/settings/billingVisibility.ts, apps/desktop/src/renderer/components/settings/browserOpenForLoginError.ts, apps/desktop/src/renderer/components/settings/cindyMakeVisibility.ts, apps/desktop/src/renderer/components/settings/computerPermissionFlow.ts, apps/desktop/src/renderer/components/settings/contacts/ContactDetailPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsImportDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsListPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsSection.tsx, apps/desktop/src/renderer/components/settings/contacts/startContactsAiSession.ts, apps/desktop/src/renderer/components/settings/dialogueDirectoryDisplay.ts, apps/desktop/src/renderer/components/settings/dualSenseSilhouette.ts, apps/desktop/src/renderer/components/settings/feishuBotPresentation.ts, apps/desktop/src/renderer/components/settings/fontFamilyValue.ts, apps/desktop/src/renderer/components/settings/gamepadLayoutPrimitives.tsx, apps/desktop/src/renderer/components/settings/gamepadSilhouetteGeom.ts, apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts, apps/desktop/src/renderer/components/settings/imBotVisibility.ts, apps/desktop/src/renderer/components/settings/imDefaultSettingsLogic.ts, apps/desktop/src/renderer/components/settings/joyConSilhouette.ts, apps/desktop/src/renderer/components/settings/modelManagementPresentation.ts, apps/desktop/src/renderer/components/settings/modelManagementState.ts, apps/desktop/src/renderer/components/settings/myDevicesModel.ts, apps/desktop/src/renderer/components/settings/providerAssetModule.ts, apps/desktop/src/renderer/components/settings/realProfilePermissionGuide.ts, apps/desktop/src/renderer/components/settings/settingsRowClasses.ts, apps/desktop/src/renderer/components/settings/settingsSearchCatalog.ts, apps/desktop/src/renderer/components/settings/settingsSearchTypes.ts, apps/desktop/src/renderer/components/settings/switchProSilhouette.ts, apps/desktop/src/renderer/components/settings/ultimateC1Silhouette.ts, apps/desktop/src/renderer/components/settings/usage/UsageBarsTooltip.tsx, apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx, apps/desktop/src/renderer/components/settings/usage/UsageDeviceSelect.tsx, apps/desktop/src/renderer/components/settings/usage/UsageFilterSelect.tsx, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.settings-search.ts, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx, apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx, apps/desktop/src/renderer/components/settings/usage/formatUsagePercent.ts, apps/desktop/src/renderer/components/settings/usage/usageCharts.css, apps/desktop/src/renderer/components/settings/usage/usageHistoryColors.ts, apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts, apps/desktop/src/renderer/components/settings/usageVisibility.ts, apps/desktop/src/renderer/components/settings/useAutomationSettingsRead.ts, apps/desktop/src/renderer/components/settings/useProviderSubscriptionCard.ts, apps/desktop/src/renderer/components/settings/useSettingsEscape.ts, apps/desktop/src/renderer/components/settings/useSettingsSectionTarget.ts, apps/desktop/src/renderer/components/settings/wizardRecommend.ts, apps/desktop/src/renderer/components/settings/workLouderCodexCommandCopy.ts, apps/desktop/src/renderer/components/settings/xboxSeriesSilhouette.ts, apps/desktop/src/renderer/styles/sortable.css | 137 | 95 | 554 | | `desktop.shell.main-layout` | desktop | 主窗口壳(标题栏 / 左右栏 / 内容区) | main BrowserWindow → renderer/index.tsx → main-entry.tsx → App → MainLayout (`#/` 受保护壳) | ChromeActions, DeviceSectionHeader, FeishuConflictDialogHost, GhostMediaLightboxHost, MainLayout, ProjectsSection, RightSidebar, RightSidebarShell, SessionShareImportWizard, Sidebar, UpdateNoticeDialog, WindowControls | apps/desktop/src/renderer/cindy-brain/GhostMediaLightboxHost.tsx, apps/desktop/src/renderer/components/UpdateNoticeDialog.tsx, apps/desktop/src/renderer/components/feishuBot/FeishuConflictDialog.tsx, apps/desktop/src/renderer/components/feishuBot/FeishuConflictDialogHost.tsx, apps/desktop/src/renderer/components/layout/AppBadgeAttentionSync.tsx, apps/desktop/src/renderer/components/layout/BrowserWebviewPool.tsx, apps/desktop/src/renderer/components/layout/ChromeActions.tsx, apps/desktop/src/renderer/components/layout/ContentHeader.tsx, apps/desktop/src/renderer/components/layout/CredentialStoreBanner.tsx, apps/desktop/src/renderer/components/layout/FadeSwitcher.tsx, apps/desktop/src/renderer/components/layout/GlobalDropImportListener.tsx, apps/desktop/src/renderer/components/layout/MainEntryRedirect.tsx, apps/desktop/src/renderer/components/layout/MainLayout.tsx, apps/desktop/src/renderer/components/layout/RightSidebar.tsx, apps/desktop/src/renderer/components/layout/RightSidebarDetach.tsx, apps/desktop/src/renderer/components/layout/RightSidebarMaximize.tsx, apps/desktop/src/renderer/components/layout/RightSidebarToggle.tsx, apps/desktop/src/renderer/components/layout/SidebarTabWindowLayout.tsx, apps/desktop/src/renderer/components/layout/SidebarWindowLayout.tsx, apps/desktop/src/renderer/components/layout/WallpaperVideo.tsx, apps/desktop/src/renderer/components/layout/chromeActionsGeometry.ts, apps/desktop/src/renderer/components/layout/chromeActionsLayout.ts, apps/desktop/src/renderer/components/layout/railChromeActions.ts, apps/desktop/src/renderer/components/layout/windowDrag.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/sidebar/AccountSwitcherDialog.tsx, apps/desktop/src/renderer/components/sidebar/AttentionDot.tsx, apps/desktop/src/renderer/components/sidebar/DatabaseSizeWarningBanner.tsx, apps/desktop/src/renderer/components/sidebar/GhostMainViewNavEntries.tsx, apps/desktop/src/renderer/components/sidebar/MobileDownloadDialog.tsx, apps/desktop/src/renderer/components/sidebar/NavigationCountBadge.tsx, apps/desktop/src/renderer/components/sidebar/NewTag.tsx, apps/desktop/src/renderer/components/sidebar/Sidebar.tsx, apps/desktop/src/renderer/components/sidebar/SidebarIconButton.tsx, apps/desktop/src/renderer/components/sidebar/SidebarNavigationCustomize.tsx, apps/desktop/src/renderer/components/sidebar/SidebarTopNav.tsx, apps/desktop/src/renderer/components/sidebar/SortableList.tsx, apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx, apps/desktop/src/renderer/components/sidebar/UserInfoSection.tsx, apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx, apps/desktop/src/renderer/components/sidebar/VendorReadinessBadge.tsx, apps/desktop/src/renderer/components/sidebar/WorktreeBadge.tsx, apps/desktop/src/renderer/components/sidebar/sidebarNavigationPrefs.ts, apps/desktop/src/renderer/components/sidebar/useHoverOpenMenu.ts, apps/desktop/src/renderer/components/title-bar/ChromeIconButton.tsx, apps/desktop/src/renderer/components/title-bar/MenuButton.tsx, apps/desktop/src/renderer/components/title-bar/WindowControls.tsx, apps/desktop/src/renderer/features/cc-agent/sidebar/DeviceSectionHeader.tsx, apps/desktop/src/renderer/features/cc-agent/sidebar/sections/ProjectsSection.tsx, apps/desktop/src/renderer/features/right-sidebar/AddTabDropdown.tsx, apps/desktop/src/renderer/features/right-sidebar/EmptyState.tsx, apps/desktop/src/renderer/features/right-sidebar/RightSidebarShell.tsx, apps/desktop/src/renderer/features/right-sidebar/TabBar.tsx, apps/desktop/src/renderer/features/right-sidebar/TabBodyErrorBoundary.tsx, apps/desktop/src/renderer/features/right-sidebar/TabDragController.tsx, apps/desktop/src/renderer/features/right-sidebar/TabGroupPanel.tsx, apps/desktop/src/renderer/features/right-sidebar/botMenuOwnerContext.ts, apps/desktop/src/renderer/features/right-sidebar/globalTabs.ts, apps/desktop/src/renderer/features/right-sidebar/hooks/useBrowserWebview.ts, apps/desktop/src/renderer/features/right-sidebar/hooks/useNativePopupSurface.ts, apps/desktop/src/renderer/features/right-sidebar/hooks/useRetainedBrowserTabHosts.ts, apps/desktop/src/renderer/features/right-sidebar/lib/browserPartition.ts, apps/desktop/src/renderer/features/right-sidebar/lib/browserWebviewPool.ts, apps/desktop/src/renderer/features/right-sidebar/lib/detachedSidebarRouting.ts, apps/desktop/src/renderer/features/right-sidebar/lib/executeSidebarCommand.ts, apps/desktop/src/renderer/features/right-sidebar/lib/lastTurnChangedFiles.ts, apps/desktop/src/renderer/features/right-sidebar/lib/mailPreview.test.ts, apps/desktop/src/renderer/features/right-sidebar/lib/mailPreview.ts, apps/desktop/src/renderer/features/right-sidebar/lib/mergeSidebarWindowBack.ts, apps/desktop/src/renderer/features/right-sidebar/lib/nativePopupTabs.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openBackgroundTasksTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openInSidebarBrowser.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openInSidebarFileBrowser.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openPluginTaskTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openRoutinesTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openSideChatTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openSubagentsTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openTerminalShortcut.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openTurnReview.ts, apps/desktop/src/renderer/features/right-sidebar/lib/popupRouter.ts, apps/desktop/src/renderer/features/right-sidebar/lib/popupTabs.ts, apps/desktop/src/renderer/features/right-sidebar/lib/rsbBrowserBridge.ts, apps/desktop/src/renderer/features/right-sidebar/lib/sidebarCommands.ts, apps/desktop/src/renderer/features/right-sidebar/lib/sidebarHostSession.tsx, apps/desktop/src/renderer/features/right-sidebar/lib/tabHosting.ts, apps/desktop/src/renderer/features/right-sidebar/lib/useTabCreationMenu.ts, apps/desktop/src/renderer/features/right-sidebar/moveTab.ts, apps/desktop/src/renderer/features/right-sidebar/pluginPlacement.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/BackgroundTasksBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/WorkflowAgentStrip.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/WorkflowProgressTree.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/chatTaskFocusIntent.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/workflowProgressModel.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/cindy-make/CindyMakeTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/cindy-make/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/FileBrowserBody.css, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/FileBrowserBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/dropExternalFile.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/fileTreeImagePreview.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/useSessionScopedTreeWidth.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/index.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/mail-preview/MailCardBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/mail-preview/MailConversationView.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/mail-preview/MailParticipant.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/mail-preview/MailPreview.test.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/mail-preview/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/OrcaWorkersAttentionIcon.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/OrcaWorkersTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/actions.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/closeDecision.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/plugin-task/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/plugin-view/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/resource-usage.css, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/subscription.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/retired-feature/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/ImageDiffPreview.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/MarkdownDiffPreview.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/PlainUnifiedDiff.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/diffRows.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/highlight.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/inlineDiff.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/useDiffHighlights.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/ReviewTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/diffExpansionPreference.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/fileTree.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/gitApplyCommand.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/markdownPreview.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useLastTurnFilter.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useReviewGitState.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useReviewSource.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/routines/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/side-chat/SideChatTabBody.test.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/side-chat/SideChatTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/side-chat/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/side-chat/state.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/ConversationStream.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/DetailView.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/RunList.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentChrome.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentToolCard.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentsBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentChangeFence.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentConversation.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentFormat.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/TerminalTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/lib/terminalFont.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/lib/useTerminalFontSettings.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/lib/xtermPool.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserChrome.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserCommentPopover.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/browserCommentEditorDraft.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/lib/parseOmnibox.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/useBrowserComment.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/useLocalHtmlAutoReload.ts, apps/desktop/src/renderer/features/right-sidebar/registry.ts, apps/desktop/src/renderer/features/right-sidebar/store.ts, apps/desktop/src/renderer/features/right-sidebar/tabAvailability.ts, apps/desktop/src/renderer/features/right-sidebar/tabGroupHost.tsx, apps/desktop/src/renderer/features/right-sidebar/tabGroupPanes.ts, apps/desktop/src/renderer/features/right-sidebar/tabGroups.ts, apps/desktop/src/renderer/features/right-sidebar/types.ts, apps/desktop/src/renderer/layout/LayoutRoot.tsx, apps/desktop/src/renderer/layout/PanelDragController.tsx, apps/desktop/src/renderer/layout/PanelDragPrototype.tsx, apps/desktop/src/renderer/layout/collapsePrefs.ts, apps/desktop/src/renderer/layout/layoutDevTools.ts, apps/desktop/src/renderer/layout/panePlacement.tsx, apps/desktop/src/renderer/layout/paneWidths.tsx, apps/desktop/src/renderer/layout/panelMaximize.tsx, apps/desktop/src/renderer/styles/generated/tokens.css, apps/desktop/src/renderer/styles/globals.css | 147 | 46 | 199 | | `desktop.skillhub.local` | desktop | SkillHub 本地技能 | hash `/skillhub/local` 及统一详情 `/skillhub/detail`(旧详情链接重定向) | InstallTargetPicker, PluginDevicePicker, PluginManagementLayout, RemoteSkillsPage, SkillCategoryFilterBar, SkillDetailLayout, SkillhubDetailRoute, SkillhubDetailView, SkillhubFeatureLayout, SkillhubHomeView, SkillhubLocalLayout, SkillhubMarketDetailView | apps/desktop/src/renderer/features/plugin/PluginDevicePicker.tsx, apps/desktop/src/renderer/features/plugin/PluginManagementLayout.tsx, apps/desktop/src/renderer/features/skillhub/RemoteSkillsPage.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubDetailRoute.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubFeatureLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubLocalLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketDetailView.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillCategoryFilterBar.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillDetailLayout.tsx, apps/desktop/src/renderer/features/skillhub/skillhub-home.css | 39 | 0 | 45 | | `desktop.skillhub.market` | desktop | SkillHub 市场 | hash `/skillhub/market`(SkillhubMarketListView) | InstallTargetPicker, MarketCard, MarketInfoEditDialog, SkillhubMarketDetailView, SkillhubMarketListView, VisibilityEditorDialog | apps/desktop/src/renderer/features/skillhub/SkillhubMarketDetailView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx, apps/desktop/src/renderer/features/skillhub/components/HomeMarketCard.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx, apps/desktop/src/renderer/features/skillhub/components/LocalSkillControls.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketCard.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketInfoEditDialog.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketInstallStatus.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketListError.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketLocalSkills.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketPreviewTree.tsx, apps/desktop/src/renderer/features/skillhub/components/OfficialSkillBadge.tsx, apps/desktop/src/renderer/features/skillhub/components/PlatformTagSelector.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillCategoryFilterBar.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillDetailLayout.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillIcon.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillTagList.tsx, apps/desktop/src/renderer/features/skillhub/components/TeamScopePicker.tsx, apps/desktop/src/renderer/features/skillhub/components/VisibilityEditorDialog.tsx | 42 | 0 | 35 | diff --git a/packages/maker-core/src/agents/base-agent.ts b/packages/maker-core/src/agents/base-agent.ts index 99811e03238..82b75f20298 100644 --- a/packages/maker-core/src/agents/base-agent.ts +++ b/packages/maker-core/src/agents/base-agent.ts @@ -876,6 +876,42 @@ export interface AgentDeps { }, ) => Promise; + /** + * Pi-only:读**活动目录**(含本机 override 合并)里某模型的图片输入能力声明。 + * `undefined` = 目录未声明。 + * 只用于旧会话切模时的能力对账(见 pi/index.ts refreshImageCapabilityOnSwitch), + * 不参与准入 —— 准入仍由会话快照的 input 与 assertImageInputSupported 负责。 + */ + readModelImageInput?: (providerId: string, modelId: string) => boolean | undefined; + + /** + * Pi-only:该模型在**活动目录**里最终的图片输入能力(目录与本机 override 合并后的结论)。 + * 与 readModelImageInput 的区别:后者只回报“显式声明了什么”(未声明即 undefined),而本项 + * 回报“最终会怎样”——未声明时按上游 #4854 的语义落到默认支持(见 resolvePiNativeProviders + * 的 `supportsImageInput === undefined ? ['text','image'] : ...`)。 + * + * 为什么需要它:能力对账要在“用户把显式声明改回跟随供应商(override 被删、声明读回 + * undefined)”时,把旧会话快照从纯文本恢复成支持。但这种状态与“目录自己就把该行标成 + * input:['text']”在 readModelImageInput 看来完全一样(都是 undefined),快照自身又已被 + * 上一轮对账改成了纯文本,两者无法区分。只有活动目录的最终结论能判定。 + * + * 拿不到(undefined)时调用方必须保持现状、不翻转快照。 + */ + readModelCatalogImageCapability?: (providerId: string, modelId: string) => boolean | undefined; + + /** + * Pi-only:该模型在**活动目录**里最终的思考档位集合(目录与本机 override 合并后的结论)。 + * + * 为什么需要它:`activeEffortSnapshot` 是 startSession 一次性解析的,而 Pi 的 set_model + * 不重读 models.json —— 用户在设置里给当前模型声明档位后,活着的子进程仍是启动时的 + * 零档位快照,于是 thinking 通道开不出来(思考继续漏进正文),必须重开会话才生效。 + * 与 readModelCatalogImageCapability 同一形状:切模是用户可见的自然同步点,在这里 + * 重新读一次活动目录即可对齐,无需重建子进程。 + * + * 返回 undefined = 拿不到结论,调用方保持现状不猜。 + */ + readModelCatalogThinkingTiers?: (providerId: string, modelId: string) => readonly string[] | undefined; + /** * Pi-only:按实际 provider/model 路由解析运行时描述符。用于启动前校验已持久化 effort, * 以及恢复已 retired 模型时补齐当前 session 的私有 models.json;结果不得进入公开 diff --git a/packages/maker-core/src/agents/pi/__tests__/pi-provider-routing.test.ts b/packages/maker-core/src/agents/pi/__tests__/pi-provider-routing.test.ts index e67b608d3fa..c4d32785475 100644 --- a/packages/maker-core/src/agents/pi/__tests__/pi-provider-routing.test.ts +++ b/packages/maker-core/src/agents/pi/__tests__/pi-provider-routing.test.ts @@ -6399,6 +6399,1129 @@ describe("Pi provider-aware model routing", () => { await handle.close(); }); + it("picks up an image-capability declaration made after session start when the user switches models", async () => { + // 会话启动时目录未声明图片能力(快照 input=['text']),带图消息被客户端门拒收;用户随后在 + // 设置里声明(本机目录 override,只落在活动目录)。旧会话的快照与 models.json 都还是启动时 + // 那份,所以要借切模这个同步点:同路由 setModel(心跳走的也是这条)先做能力对账,不一致就 + // 热写 models.json 并 switch_session 让子进程 ModelConfig.load 重载。 + const declared = { image: false }; + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "native-declared", + name: "Native Declared", + baseUrl: "http://declared.test", + api: "openai-completions", + models: [{ id: "local-model", input: ["text"] }], + }, + ], + env: {}, + }), + ), + // 活动目录(含本机 override)的声明:未声明 undefined / 声明后 true。 + readModelImageInput: (providerId, modelId) => + providerId === "native-declared" && modelId === "local-model" && declared.image + ? true + : undefined, + }); + const handle = await agent.startSession({ + sessionId: "image-declared-after-start", + workingDir: cwd, + model: "local-model", + providerId: "native-declared", + }); + const imagePath = path.join(cwd, "declared.png"); + writeFileSync( + imagePath, + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY4YAAAAASUVORK5CYII=", + "base64", + ), + ); + const imageMessage = { + type: "user" as const, + content: [ + { + type: "image" as const, + path: imagePath, + managedUrl: "xdt-image://pi-managed/declared.png", + }, + ], + }; + const readNativeInput = (): string[] => { + const modelsJson = JSON.parse( + readFileSync( + path.join(captured.env.PI_CODING_AGENT_DIR as string, "models.json"), + "utf8", + ), + ) as { + providers: Record }>; + }; + const block = + modelsJson.providers["native-declared"] ?? + modelsJson.providers["cindy-byom-native-declared"]; + return block!.models.find((entry) => entry.id === "local-model")!.input; + }; + + // 基线:声明前客户端门拒收,请求根本不出去,models.json 也没有 image。 + captured.requests.length = 0; + await expect(handle.send(imageMessage)).rejects.toMatchObject({ + code: "PI_IMAGE_INPUT_UNSUPPORTED", + }); + expect(captured.requests).toHaveLength(0); + expect(readNativeInput()).toEqual(["text"]); + + // 用户在设置里声明「支持图片输入」(目录变化,快照不变)。 + declared.image = true; + + // 切模(目标 = 当前同路由,与心跳同路径)触发对账:热写 + 子进程重载。 + captured.requests.length = 0; + await handle.setModel!("local-model", { providerId: "native-declared" }); + expect(readNativeInput()).toEqual(["text", "image"]); + expect(captured.requests.map((request) => request.type)).toContain("switch_session"); + + // 同一条消息现在放行。 + captured.requests.length = 0; + await handle.send(imageMessage); + expect(captured.requests).toContainEqual( + expect.objectContaining({ + type: "prompt", + images: [expect.objectContaining({ type: "image" })], + }), + ); + await handle.close(); + }); + + it("keeps the session context budget in settings.json across an image capability reload", async () => { + // 图片声明重写 settings.json 时必须带上 sessionContextWindowBudget:漏传会把逐模型 + // compaction.modelOverrides 按模型自身窗口重算(fallback 16_384),任务级预算静默失效。 + const declared = { image: false }; + const deps = byomDeps( + async () => ({ + providers: [ + { + id: "native-budget", + name: "Native Budget", + baseUrl: "http://budget.test", + api: "openai-completions", + models: [{ id: "model-a", input: ["text"], contextWindow: 200_000 }], + }, + ], + env: {}, + }), + ); + deps.runtimeConfig = { ...deps.runtimeConfig, piAutoCompactThresholdPct: 75 }; + const agent = new PiAgent({ + ...deps, + readModelImageInput: (providerId, modelId) => + providerId === "native-budget" && modelId === "model-a" && declared.image + ? true + : undefined, + }); + const handle = await agent.startSession({ + sessionId: "image-reload-budget", + workingDir: cwd, + model: "model-a", + providerId: "native-budget", + contextWindowBudget: 50_000, + }); + const settingsPath = path.join(captured.env.PI_CODING_AGENT_DIR!, "settings.json"); + const readReserve = () => + (JSON.parse(readFileSync(settingsPath, "utf8")) as { + compaction?: { modelOverrides?: Record }; + }).compaction?.modelOverrides?.["native-budget/model-a"]?.reserveTokens; + // 预算口径:200_000 - 50_000 × 75% = 162_500。 + expect(readReserve()).toBe(162_500); + + // 图片声明变更触发的对账重写 settings.json,预算不能被冲掉。 + declared.image = true; + await handle.setModel!("model-a", { providerId: "native-budget" }); + expect(captured.runtimeModel).toBe("model-a"); + expect(readReserve()).toBe(162_500); + await handle.close(); + }); + + it("reapplies the session thinking level after an image capability reload reissues set_model", async () => { + // reload 的 switch_session/set_model 会把思考档位重置成模型默认(pi CLI 行为),而重载 + // 路径不经过 switchModel 末尾的档位恢复 —— 发图触发的对账、同路由切模触发的对账早退后, + // 子进程实际跑在默认档而 UI 仍显示原档位。重载确认路由后必须把会话当前档位重发一遍。 + const declared = { image: false }; + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "native-reload-tier", + name: "Native Reload Tier", + baseUrl: "http://reload-tier.test", + api: "openai-completions", + models: [ + { + id: "model-a", + input: ["text"], + reasoning: true, + thinkingLevelMap: { low: "low", max: "max" }, + }, + { + id: "model-b", + input: ["text"], + reasoning: true, + thinkingLevelMap: { low: "low", max: "max" }, + }, + ], + }, + ], + env: {}, + }), + ), + readModelImageInput: (providerId, modelId) => + providerId === "native-reload-tier" && modelId === "model-b" && declared.image + ? true + : undefined, + }); + const handle = await agent.startSession({ + sessionId: "image-reload-thinking-restore", + workingDir: cwd, + model: "model-a", + providerId: "native-reload-tier", + effort: "max", + }); + await handle.setModel!("model-b", { providerId: "native-reload-tier", effort: "max" }); + expect(captured.runtimeModel).toBe("model-b"); + + // 用户此刻声明图片能力,同路由 setModel 触发对账:switch_session 重建会话 + 重发 + // set_model(档位被重置)→ 修复后必须跟着一条 set_thinking_level 把会话档位找回来。 + declared.image = true; + captured.requests.length = 0; + await handle.setModel!("model-b", { providerId: "native-reload-tier" }); + + const requestTypes = captured.requests.map((request) => request.type); + expect(requestTypes).toContain("switch_session"); + expect(requestTypes.indexOf("set_model")).toBeGreaterThan( + requestTypes.indexOf("switch_session"), + ); + expect(requestTypes.indexOf("set_thinking_level")).toBeGreaterThan( + requestTypes.indexOf("set_model"), + ); + expect( + captured.requests.find((request) => request.type === "set_thinking_level"), + ).toEqual({ type: "set_thinking_level", level: "max" }); + await handle.close(); + }); + + it("restores the confirmed thinking-off state after an image capability reload", async () => { + // 关闭开关只发 off、不更新 mutableEffort;reload 恢复若只看 mutableEffort,就会把用户 + // 关掉的思考悄悄重开(等待时间与费用都涨)。恢复时优先还原已确认的关闭状态。 + const declared = { image: false }; + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "native-off-restore", + name: "Native Off Restore", + baseUrl: "http://off-restore.test", + api: "openai-completions", + models: [ + { + id: "local-model", + input: ["text"], + reasoning: true, + thinkingLevelMap: { low: "low", max: "max" }, + }, + ], + }, + ], + env: {}, + }), + ), + readModelImageInput: (providerId, modelId) => + providerId === "native-off-restore" && modelId === "local-model" && declared.image + ? true + : undefined, + }); + const handle = await agent.startSession({ + sessionId: "image-reload-thinking-off", + workingDir: cwd, + model: "local-model", + providerId: "native-off-restore", + effort: "max", + }); + // 用户关掉思考;随后声明图片能力,直接发图触发对账重载。 + await handle.setThinkingEnabled!(false); + declared.image = true; + const imagePath = path.join(cwd, "off-restore.png"); + writeFileSync( + imagePath, + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY4YAAAAASUVORK5CYII=", + "base64", + ), + ); + captured.requests.length = 0; + await handle.send({ + type: "user" as const, + content: [ + { + type: "image" as const, + path: imagePath, + managedUrl: "xdt-image://pi-managed/off-restore.png", + }, + ], + }); + + // 重载发生了(switch_session),之后的恢复必须是 off(而不是 mutableEffort 的 max)。 + const requestTypes = captured.requests.map((request) => request.type); + expect(requestTypes).toContain("switch_session"); + const restores = captured.requests.filter( + (request) => request.type === "set_thinking_level", + ); + expect(restores).toEqual([{ type: "set_thinking_level", level: "off" }]); + await handle.close(); + }); + + it("restores the applied effort level, not the stale off state, when thinking was re-enabled via setEffort", async () => { + // 关闭思考后用 setEffort 应用新档位:应用档位本身就意味着思考开着,关闭记录必须被 + // 覆盖。否则 reload 恢复会把刚应用的档位用 off 关掉,后续回答不再按用户选的档位跑。 + const declared = { image: false }; + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "native-re-enable", + name: "Native Re-enable", + baseUrl: "http://re-enable.test", + api: "openai-completions", + models: [ + { + id: "local-model", + input: ["text"], + reasoning: true, + thinkingLevelMap: { low: "low", max: "max" }, + }, + ], + }, + ], + env: {}, + }), + ), + readModelImageInput: (providerId, modelId) => + providerId === "native-re-enable" && modelId === "local-model" && declared.image + ? true + : undefined, + }); + const handle = await agent.startSession({ + sessionId: "image-reload-re-enable", + workingDir: cwd, + model: "local-model", + providerId: "native-re-enable", + effort: "max", + }); + await handle.setThinkingEnabled!(false); + await handle.setEffort!("max"); + declared.image = true; + const imagePath = path.join(cwd, "re-enable.png"); + writeFileSync( + imagePath, + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY4YAAAAASUVORK5CYII=", + "base64", + ), + ); + captured.requests.length = 0; + await handle.send({ + type: "user" as const, + content: [ + { + type: "image" as const, + path: imagePath, + managedUrl: "xdt-image://pi-managed/re-enable.png", + }, + ], + }); + + // 重载后的恢复必须是刚应用的 max,而不是过期的关闭状态。 + expect(captured.requests.map((request) => request.type)).toContain("switch_session"); + expect( + captured.requests.filter((request) => request.type === "set_thinking_level"), + ).toEqual([{ type: "set_thinking_level", level: "max" }]); + await handle.close(); + }); + + it("does not rewrite models.json or reload the runtime when the catalog already matches the snapshot", async () => { + // 心跳会反复以同路由下发 setModel;声明与会话快照一致时必须零 I/O —— 不热写、不 switch_session。 + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "native-vision", + name: "Native Vision", + baseUrl: "http://vision.test", + api: "openai-completions", + models: [{ id: "local-model", input: ["text", "image"] }], + }, + ], + env: {}, + }), + ), + readModelImageInput: () => true, + }); + const handle = await agent.startSession({ + sessionId: "image-already-matches", + workingDir: cwd, + model: "local-model", + providerId: "native-vision", + }); + const modelsPath = path.join( + captured.env.PI_CODING_AGENT_DIR as string, + "models.json", + ); + const before = readFileSync(modelsPath, "utf8"); + captured.requests.length = 0; + await handle.setModel!("local-model", { providerId: "native-vision" }); + expect(readFileSync(modelsPath, "utf8")).toBe(before); + expect(captured.requests.map((request) => request.type)).not.toContain("switch_session"); + await handle.close(); + }); + + it("refreshes on the first image send when a capability declaration appears after session start without a model switch", async () => { + // 声明可能发生在会话启动之后、且用户不切模直接发图:客户端门在拒收前就地补一次能力 + // 对账(与切模路径同一函数、同一失败语义),补上即放行;盘上 models.json 同步热写、 + // 子进程 switch_session 重载。未声明时仍拒收(见另一用例的基线),且对账零副作用。 + const declared = { image: false }; + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "native-send-refresh", + name: "Native Send Refresh", + baseUrl: "http://send-refresh.test", + api: "openai-completions", + models: [{ id: "local-model", input: ["text"] }], + }, + ], + env: {}, + }), + ), + readModelImageInput: () => (declared.image ? true : undefined), + }); + const handle = await agent.startSession({ + sessionId: "image-declared-send-only", + workingDir: cwd, + model: "local-model", + providerId: "native-send-refresh", + }); + const imagePath = path.join(cwd, "send-refresh.png"); + writeFileSync( + imagePath, + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY4YAAAAASUVORK5CYII=", + "base64", + ), + ); + const imageMessage = { + type: "user" as const, + content: [ + { + type: "image" as const, + path: imagePath, + managedUrl: "xdt-image://pi-managed/send-refresh.png", + }, + ], + }; + const modelsPath = path.join( + captured.env.PI_CODING_AGENT_DIR as string, + "models.json", + ); + const readSendRefreshInput = (): string[] => { + const modelsJson = JSON.parse(readFileSync(modelsPath, "utf8")) as { + providers: Record }>; + }; + const block = + modelsJson.providers["native-send-refresh"] ?? + modelsJson.providers["cindy-byom-native-send-refresh"]; + return block!.models.find((entry) => entry.id === "local-model")!.input; + }; + expect(readSendRefreshInput()).toEqual(["text"]); + + // 会话启动后才声明;不切模,直接发图。 + declared.image = true; + captured.requests.length = 0; + await handle.send(imageMessage); + + // 对账生效:盘上热写 + 子进程重载 + prompt 带图放行。 + expect(readSendRefreshInput()).toEqual(["text", "image"]); + expect(captured.requests.map((request) => request.type)).toContain("switch_session"); + expect(captured.requests).toContainEqual( + expect.objectContaining({ + type: "prompt", + images: [expect.objectContaining({ type: "image" })], + }), + ); + await handle.close(); + }); + + it("keeps the session route when a late capability declaration is reconciled on a same-route model switch", async () => { + // switch_session 按进程启动时的 --provider/--model 重建 AgentSession:对账里如果只 + // switch_session 而不重放当前路由,会话内切过模的会话会被静默拽回启动路由 —— 同路由 + // no-op 与发图都不会再打 set_model,提示词发给错误的模型且没有任何报错。 + const declared = { image: false }; + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "native-route-keep", + name: "Native Route Keep", + baseUrl: "http://route-keep.test", + api: "openai-completions", + models: [ + { id: "model-a", input: ["text"] }, + { id: "model-b", input: ["text"] }, + ], + }, + ], + env: {}, + }), + ), + readModelImageInput: (providerId, modelId) => + providerId === "native-route-keep" && modelId === "model-b" && declared.image + ? true + : undefined, + }); + const handle = await agent.startSession({ + sessionId: "image-route-keep", + workingDir: cwd, + model: "model-a", + providerId: "native-route-keep", + }); + await handle.setModel!("model-b", { providerId: "native-route-keep" }); + expect(captured.runtimeModel).toBe("model-b"); + + // 会话里已切到 model-b;用户此刻声明图片能力,下一次心跳以同路由 setModel(model-b) + // 触发对账(与 no-op 分支同路径)。 + declared.image = true; + captured.requests.length = 0; + await handle.setModel!("model-b", { providerId: "native-route-keep" }); + + // 对账生效,且子进程仍在 model-b(而不是被 switch_session 拽回 model-a)。 + expect(captured.runtimeModel).toBe("model-b"); + const requestTypes = captured.requests.map((request) => request.type); + expect(requestTypes).toContain("switch_session"); + expect(requestTypes.indexOf("set_model")).toBeGreaterThan( + requestTypes.indexOf("switch_session"), + ); + await handle.close(); + }); + + it("realigns the thinking tier snapshot when the user declares tiers on the current model", async () => { + // 用户在设置里给**当前**模型声明思考档位后,活着的子进程仍是启动时的零档位快照: + // activeEffortSnapshot 是 startSession 一次性解析的,Pi 的 set_model 不重读 models.json。 + // 切模(含同路由 no-op)是用户可见的自然同步点,必须在这里对齐,否则 thinking 通道 + // 永远开不出来、推理继续漏进正文,只有重开会话才生效。 + const declared = { tiers: [] as string[] | undefined }; + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "tier-realign", + name: "Tier Realign", + baseUrl: "http://tier-realign.test", + api: "openai-completions", + // 目录声明支持思考但只给 high 一档:启动快照非空,便于观察对齐行为。 + models: [{ id: "model-a", input: ["text"], thinkingLevelMap: { high: "high" } }], + }, + ], + env: {}, + }), + ), + readModelCatalogThinkingTiers: (providerId, modelId) => + providerId === "tier-realign" && modelId === "model-a" ? declared.tiers : undefined, + }); + const handle = await agent.startSession({ + sessionId: "thinking-tier-realign", + workingDir: cwd, + model: "model-a", + providerId: "tier-realign", + }); + + // 用户声明了更多档位 → 同路由 setModel(no-op) 必须把快照对齐过去。 + declared.tiers = ["low", "high", "max"]; + captured.requests.length = 0; + await handle.setModel!("model-a", { providerId: "tier-realign" }); + + // 快照对齐后 setEffort 不再被「零档位快照」挡住:会真的发出 set_thinking_level。 + await handle.setEffort!("max"); + const requestTypes = captured.requests.map((request) => request.type); + expect(requestTypes).toContain("set_thinking_level"); + await handle.close(); + }); + + it("preflights a freshly declared effort tier against the catalog, not the frozen startup snapshot", async () => { + // 用户在设置里给 model-b 声明了新档位 max,随即带 effort=max 切模。启动时冻结的 + // nextEffortSnapshot 里没有 max,若预检用它就会在 RPC 发出前误判「不可用」→ 切模失败。 + // 预检必须用对账读到的目录结论;活动快照仍只在切模成功后落定。 + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "tier-preflight", + name: "Tier Preflight", + baseUrl: "http://tier-preflight.test", + api: "openai-completions", + models: [ + { id: "model-a", input: ["text"], thinkingLevelMap: { high: "high" } }, + { id: "model-b", input: ["text"], thinkingLevelMap: { low: "low" } }, + ], + }, + ], + env: {}, + }), + ), + // model-b 的目录最终档位含 max(用户刚声明的),而随包目录只给了 low。 + readModelCatalogThinkingTiers: (providerId, modelId) => + providerId === "tier-preflight" && modelId === "model-b" + ? ["low", "medium", "high", "max"] + : undefined, + }); + const handle = await agent.startSession({ + sessionId: "thinking-tier-preflight", + workingDir: cwd, + model: "model-a", + providerId: "tier-preflight", + }); + + // 带新声明的 effort 切模:预检放行、set_model 发出、会话落到 model-b。 + const beforeSwitch = captured.requests.length; + await handle.setModel!("model-b", { providerId: "tier-preflight", effort: "max" }); + expect(captured.runtimeModel).toBe("model-b"); + + // 切模请求本身就必须应用用户显式指定的档位:pi CLI 在 set_model 后会把思考档位重置 + // 成新模型自己的默认,恢复门若仍按启动时冻结的快照(只有 low)判断,就会静默不发 + // set_thinking_level,实际跑在默认档上。恢复门必须用落定后的活动快照(含新声明的 max)。 + expect( + captured.requests.slice(beforeSwitch).filter( + (request) => request.type === "set_thinking_level", + ), + ).toEqual([{ type: "set_thinking_level", level: "max" }]); + + // 切模成功后活动快照已含新档位,随后 setEffort(max) 能真的下发。 + captured.requests.length = 0; + await handle.setEffort!("max"); + expect( + captured.requests.filter((request) => request.type === "set_thinking_level"), + ).not.toHaveLength(0); + await handle.close(); + }); + + it("still realigns when the target catalog equals the current snapshot, not the frozen one", async () => { + // 当前模型的快照与目标模型目录结论碰巧相同时,对账也不能跳过:目标模型在本进程 + // 启动时冻结的快照还停在旧声明,set_model 后 pi 又会重置到目标默认档。若用「相同」 + // 判空,preflight 退回冻结快照 → 带新档切入直接被误拒;不带 effort 切则落定过期值、 + // 静默掉默认档。 + const declaredB = { tiers: ["high"] as string[] | undefined }; + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "tier-equal", + name: "Tier Equal", + baseUrl: "http://tier-equal.test", + api: "openai-completions", + models: [ + { id: "model-a", input: ["text"], reasoning: true, thinkingLevelMap: { high: "high", max: "max" } }, + { id: "model-b", input: ["text"], reasoning: true, thinkingLevelMap: { high: "high" } }, + ], + }, + ], + env: {}, + }), + ), + readModelCatalogThinkingTiers: (providerId, modelId) => + providerId === "tier-equal" && modelId === "model-b" ? declaredB.tiers : undefined, + }); + const handle = await agent.startSession({ + sessionId: "thinking-tier-equal", + workingDir: cwd, + model: "model-a", + providerId: "tier-equal", + }); + + // 会话期间 model-b 的声明改为与当前模型 model-a 的快照完全相同的集合(启动时 + // model-b 冻结的快照还没有 max)。宣告集合碰巧相同,对账也不能跳过。 + declaredB.tiers = ["minimal", "low", "medium", "high", "max"]; + const beforeSwitch = captured.requests.length; + await handle.setModel!("model-b", { providerId: "tier-equal", effort: "max" }); + expect(captured.runtimeModel).toBe("model-b"); + + // 恢复门按落定快照(含 max)发出档位,而不是静默跑在 pi 默认档上。 + expect( + captured.requests.slice(beforeSwitch).filter( + (request) => request.type === "set_thinking_level", + ), + ).toEqual([{ type: "set_thinking_level", level: "max" }]); + await handle.close(); + }); + + it("clears the explicitly-off marker when a thinkingEnabled carrier re-opens thinking", async () => { + // 用户先关思考,再带 thinkingEnabled:true + 原档位切到另一个推理模型:恢复门与 + // 归一化算出同一档,去重分支只省重复 RPC,仍必须清掉 thinkingExplicitlyOff—— + // 否则紧随的无载体切模仍按「用户明确关过」跳过恢复,刚重新开启的思考被误判回关闭。 + const denseMap = { + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: "max", + } as const; + const agent = new PiAgent( + byomDeps( + async () => ({ + providers: [ + { + id: "native-a", + name: "Native A", + baseUrl: "http://a.test", + api: "openai-responses", + models: [ + { id: "model-a", reasoning: true, thinkingLevelMap: { ...denseMap } }, + { id: "model-b", reasoning: true, thinkingLevelMap: { ...denseMap } }, + { id: "model-c", reasoning: true, thinkingLevelMap: { ...denseMap } }, + ], + }, + ], + env: {}, + }), + [ + { id: "model-a", displayName: "A", contextWindow: 200_000, efforts: ["low", "high"], defaultEffort: "low" }, + { id: "model-b", displayName: "B", contextWindow: 200_000, efforts: ["low", "high"], defaultEffort: "low" }, + { id: "model-c", displayName: "C", contextWindow: 200_000, efforts: ["low", "high"], defaultEffort: "low" }, + ], + ), + ); + const handle = await agent.startSession({ + sessionId: "thinking-reopen-clears-off-marker", + workingDir: cwd, + model: "model-a", + providerId: "native-a", + effort: "high", + }); + + await handle.setThinkingEnabled!(false); + const beforeReopen = captured.requests.length; + await handle.setModel!("model-b", { + providerId: "native-a", + effort: "high", + thinkingEnabled: true, + }); + // 载体切模本身必须应用 high(恢复门)。 + expect( + captured.requests.slice(beforeReopen).filter( + (request) => request.type === "set_thinking_level", + ), + ).toEqual([{ type: "set_thinking_level", level: "high" }]); + + // 紧随的无载体切模:关闭标记已被清除,档位恢复必须继续生效。 + const beforeCarrierless = captured.requests.length; + await handle.setModel!("model-c", { providerId: "native-a" }); + expect( + captured.requests.slice(beforeCarrierless).filter( + (request) => request.type === "set_thinking_level", + ), + ).toEqual([{ type: "set_thinking_level", level: "high" }]); + await handle.close(); + }); + + it("does not realign the thinking tier snapshot when the model switch fails", async () => { + // 对账发生在切模的最早入口(成员校验 / effort 校验 / set_model RPC 之前)。若在那里 + // 就改写 activeEffortSnapshot,set_model 被拒后子进程仍停在旧模型,快照却已是失败 + // 目标的档位 —— 随后的思考开关与 effort 校验会按错误模型执行。落定只允许在成功路径。 + // + // 用 set_model RPC 失败(success:false)而不是「模型不在目录」:后者对账返回 + // undefined、压根不进污染分支,测不到东西。 + const declared = { tiers: ["low", "medium", "high", "max"] as string[] | undefined }; + // 只拦 set_model;get_state 等其余命令必须走默认桩,否则会话起不来。 + const defaultHandler = captured.requestHandler; + captured.requestHandler = async (command) => { + if (command.type === "set_model") return { success: false, error: "injected failure" }; + return defaultHandler + ? await defaultHandler(command) + : command.type === "get_state" + ? { success: true, data: { sessionFile: "/mock/s.jsonl", model: { contextWindow: 200_000 } } } + : { success: true, data: {} }; + }; + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "tier-rollback", + name: "Tier Rollback", + baseUrl: "http://tier-rollback.test", + api: "openai-completions", + models: [ + { id: "model-a", input: ["text"], thinkingLevelMap: { high: "high" } }, + { id: "model-b", input: ["text"], thinkingLevelMap: { low: "low" } }, + ], + }, + ], + env: {}, + }), + ), + readModelCatalogThinkingTiers: (providerId, modelId) => + providerId === "tier-rollback" && modelId === "model-b" ? declared.tiers : undefined, + }); + const handle = await agent.startSession({ + sessionId: "thinking-tier-rollback", + workingDir: cwd, + model: "model-a", + providerId: "tier-rollback", + }); + + // 切到 model-b(目录里有,声明了四档),但 set_model 失败 → 仍停在 model-a。 + await expect(handle.setModel!("model-b", { providerId: "tier-rollback" })).rejects.toThrow(); + expect(captured.runtimeModel).toBe("model-a"); + + // 快照必须仍是 model-a 的(只有 high)。若被 model-b 的四档污染,max 会被当合法档位 + // 直接下发;快照仍是 model-a 时 assertStartupEffortAllowed 必须拒 —— 抛错即证据。 + // (报错文案随上游演进,这里只断言「拒绝」这个事实,不锁死具体措辞。) + captured.requests.length = 0; + await expect(handle.setEffort!("max")).rejects.toThrow(/set_thinking_level refused/); + expect( + captured.requests.filter((request) => request.type === "set_thinking_level"), + ).toHaveLength(0); + await handle.close(); + }); + + it("rejects images when the declaration flips to unsupported without a model switch", async () => { + // 反向对账:会话启动时快照是「支持图片」,用户后来把声明改成不支持且不切模 —— + // 先查快照就放行会把图片发给一个已声明不支持的模型。对账必须在信任快照之前跑。 + const declared = { image: true }; + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "native-revoke", + name: "Native Revoke", + baseUrl: "http://revoke.test", + api: "openai-completions", + models: [{ id: "local-model", input: ["text", "image"] }], + }, + ], + env: {}, + }), + ), + readModelImageInput: () => declared.image, + }); + const handle = await agent.startSession({ + sessionId: "image-revoked-after-start", + workingDir: cwd, + model: "local-model", + providerId: "native-revoke", + }); + const imagePath = path.join(cwd, "revoke.png"); + writeFileSync( + imagePath, + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY4YAAAAASUVORK5CYII=", + "base64", + ), + ); + const imageMessage = { + type: "user" as const, + content: [ + { + type: "image" as const, + path: imagePath, + managedUrl: "xdt-image://pi-managed/revoke.png", + }, + ], + }; + const readRevokeInput = (): string[] => { + const modelsJson = JSON.parse( + readFileSync(path.join(captured.env.PI_CODING_AGENT_DIR as string, "models.json"), "utf8"), + ) as { providers: Record }> }; + const block = + modelsJson.providers["native-revoke"] ?? modelsJson.providers["cindy-byom-native-revoke"]; + return block!.models.find((entry) => entry.id === "local-model")!.input; + }; + + // 基线:声明支持时放行。 + captured.requests.length = 0; + await handle.send(imageMessage); + expect(captured.requests.map((request) => request.type)).toContain("prompt"); + + // 改成「不支持图片」,不切模直接发图:必须拒收,且盘上快照同步回纯文本。 + declared.image = false; + captured.requests.length = 0; + await expect(handle.send(imageMessage)).rejects.toMatchObject({ + code: "PI_IMAGE_INPUT_UNSUPPORTED", + }); + expect(captured.requests.map((request) => request.type)).not.toContain("prompt"); + expect(readRevokeInput()).toEqual(["text"]); + await handle.close(); + }); + + it("restores image support when the user returns to following the catalog", async () => { + // 用户先显式声明「不支持」(会话快照被写成纯文本),随后改回「跟随供应商」:override 被删、 + // 声明读回 undefined。此时目录未声明 = 默认支持(上游 #4854),旧会话必须恢复支持图片 —— + // 否则只能重开任务。关键在于区分「改回继承」与「目录自己标了 input:['text']」:两者在 + // readModelImageInput 看来都是 undefined,只有活动目录的最终结论(readModelCatalogImageCapability) + // 能分辨,所以对账在目录未声明时必须查这个钩子。 + const declared = { image: true as boolean | null }; // null = 改回跟随供应商(未声明) + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "native-restore", + name: "Native Restore", + baseUrl: "http://restore.test", + api: "openai-completions", + // 目录本身不标 input(未声明 = 支持),所以“恢复继承”应当回到支持。 + models: [{ id: "local-model", input: ["text", "image"] }], + }, + ], + env: {}, + }), + ), + // 目录 spec 说支持(input 含 image),用户先显式声明不支持、随后改回继承。 + // readModelImageInput 只回报“显式声明了什么”,两种状态在它看来都是 undefined。 + readModelImageInput: (providerId, modelId) => + providerId === "native-restore" && modelId === "local-model" && declared.image !== null + ? declared.image + : undefined, + // 活动目录的最终结论:未声明 → 默认支持。 + readModelCatalogImageCapability: (providerId, modelId) => + providerId === "native-restore" && modelId === "local-model" ? true : undefined, + }); + const handle = await agent.startSession({ + sessionId: "image-restored-after-inherit", + workingDir: cwd, + model: "local-model", + providerId: "native-restore", + }); + const imagePath = path.join(cwd, "restore.png"); + writeFileSync( + imagePath, + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY4YAAAAASUVORK5CYII=", + "base64", + ), + ); + const imageMessage = { + type: "user" as const, + content: [ + { + type: "image" as const, + path: imagePath, + managedUrl: "xdt-image://pi-managed/restore.png", + }, + ], + }; + const readRestoreInput = (): string[] => { + const modelsJson = JSON.parse( + readFileSync(path.join(captured.env.PI_CODING_AGENT_DIR as string, "models.json"), "utf8"), + ) as { providers: Record }> }; + const block = + modelsJson.providers["native-restore"] ?? modelsJson.providers["cindy-byom-native-restore"]; + return block!.models.find((entry) => entry.id === "local-model")!.input; + }; + + // 先显式声明「不支持」:目录 spec 说支持,但用户声明压过它 → 快照被写成纯文本,带图被拒收。 + declared.image = false; + captured.requests.length = 0; + await handle.setModel!("local-model", { providerId: "native-restore" }); + await expect(handle.send(imageMessage)).rejects.toMatchObject({ + code: "PI_IMAGE_INPUT_UNSUPPORTED", + }); + expect(readRestoreInput()).toEqual(["text"]); + + // 用户改回「跟随供应商」:声明读回 undefined,目录未声明 = 默认支持。 + declared.image = null; + captured.requests.length = 0; + await handle.setModel!("local-model", { providerId: "native-restore" }); + expect(readRestoreInput()).toEqual(["text", "image"]); + + // 旧会话恢复支持图片输入,无需重开任务。 + captured.requests.length = 0; + await handle.send(imageMessage); + expect(captured.requests).toContainEqual( + expect.objectContaining({ + type: "prompt", + images: [expect.objectContaining({ type: "image" })], + }), + ); + await handle.close(); + }); + + it("skips the route replay when read-back already matches (spawn-only routes)", async () => { + // spawn 能靠 custom model id 跑在 Pi 自带目录没有的路由上,对这种路由重发 set_model 会 + // 被 Pi 拒(见 switchModel 开头同路由 no-op 的说明);而重放失败会 terminate 整个任务 —— + // 只改一个能力声明就杀掉正在跑的任务。switch_session 后先 get_state 读回,读回已经等于 + // 当前路由时不得再发 set_model。 + const declared = { image: false }; + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "native-spawn-only", + name: "Native Spawn Only", + baseUrl: "http://spawn-only.test", + api: "openai-completions", + models: [{ id: "local-model", input: ["text"] }], + }, + ], + env: {}, + }), + ), + readModelImageInput: (providerId, modelId) => + providerId === "native-spawn-only" && modelId === "local-model" && declared.image + ? true + : undefined, + }); + const handle = await agent.startSession({ + sessionId: "image-spawn-only-route", + workingDir: cwd, + model: "local-model", + providerId: "native-spawn-only", + }); + + declared.image = true; + captured.requests.length = 0; + // 真机上的 spawn-only 路由:任何 set_model 都会被拒。 + captured.requestHandler = async (command) => + command.type === "set_model" + ? { success: false, error: 'Model "local-model" not found for provider "xai"' } + : { success: true, data: {} }; + await handle.setModel!("local-model", { providerId: "native-spawn-only" }); + + const requestTypes = captured.requests.map((request) => request.type); + expect(requestTypes).toContain("switch_session"); + expect(requestTypes).not.toContain("set_model"); + expect(captured.closes).toBe(0); + // 会话仍然可用:对账后路由没被拽走。 + expect(captured.runtimeModel).toBe("local-model"); + await handle.send({ type: "user", content: "still alive" }); + expect(captured.requests.map((request) => request.type)).toContain("prompt"); + await handle.close(); + }); + + it("terminates the session when the route cannot be replayed onto a different model", async () => { + // 会话内切过模:switch_session 会把子进程拽回启动路由,读回与当前路由不一致就必须重放。 + // 重放被拒 = 子进程确实跑在错误的路由上,继续用会把提示词静默发给错误的模型 —— + // 保留 pending 重试也修不好,只能 fail-closed 终止(且不留下“已重载”的错误结论)。 + const declared = { image: false }; + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "native-route-lost", + name: "Native Route Lost", + baseUrl: "http://route-lost.test", + api: "openai-completions", + models: [ + { id: "model-a", input: ["text"] }, + { id: "model-b", input: ["text"] }, + ], + }, + ], + env: {}, + }), + ), + readModelImageInput: (providerId, modelId) => + providerId === "native-route-lost" && modelId === "model-b" && declared.image + ? true + : undefined, + }); + const handle = await agent.startSession({ + sessionId: "image-route-replay-rejected", + workingDir: cwd, + model: "model-a", + providerId: "native-route-lost", + }); + await handle.setModel!("model-b", { providerId: "native-route-lost" }); + expect(captured.runtimeModel).toBe("model-b"); + + declared.image = true; + const baselineSetModels = captured.requests.filter( + (request) => request.type === "set_model", + ).length; + captured.requestHandler = async (command) => + command.type === "get_state" && captured.runtimeModel === "model-b" + ? { success: true, data: { model: { provider: "native-route-lost", id: "model-b" } } } + : command.type === "set_model" + ? { success: false, error: 'Model "model-b" not found for provider "native-route-lost"' } + : { success: true, data: {} }; + await expect( + handle.setModel!("model-b", { providerId: "native-route-lost" }), + ).rejects.toThrow(/PI_CATALOG_RELOAD_UNCONFIRMED/); + expect(captured.closes).toBe(1); + expect( + captured.requests.filter((request) => request.type === "set_model").length, + ).toBeGreaterThan(baselineSetModels); + await handle.close(); + }); + + it("replays the previous route but lands on the new model when switching models", async () => { + // 非 no-op 切模:对账在 set_model 之前跑,重放恢复的是**旧**路由,随后切模再指向目标 —— + // 最终必须落在目标模型上,不能因为对账的重放把切模吃掉。 + const declared = { image: false }; + const agent = new PiAgent({ + ...byomDeps( + async () => ({ + providers: [ + { + id: "native-switch-route", + name: "Native Switch Route", + baseUrl: "http://switch-route.test", + api: "openai-completions", + models: [ + { id: "model-a", input: ["text"] }, + { id: "model-b", input: ["text"] }, + ], + }, + ], + env: {}, + }), + ), + readModelImageInput: (providerId, modelId) => + providerId === "native-switch-route" && modelId === "model-b" && declared.image + ? true + : undefined, + }); + const handle = await agent.startSession({ + sessionId: "image-switch-lands-on-target", + workingDir: cwd, + model: "model-a", + providerId: "native-switch-route", + }); + + // 目标模型 model-b 的声明与会话快照不一致 → 切模路径里先对账后切模。 + declared.image = true; + captured.requests.length = 0; + await handle.setModel!("model-b", { providerId: "native-switch-route" }); + expect(captured.runtimeModel).toBe("model-b"); + const requestTypes = captured.requests.map((request) => request.type); + expect(requestTypes).toContain("switch_session"); + expect(requestTypes.lastIndexOf("set_model")).toBeGreaterThan( + requestTypes.indexOf("switch_session"), + ); + await handle.close(); + }); + it("waits through Pi preflight compaction when accepting a prompt", async () => { const agent = new PiAgent( byomDeps(async () => ({ providers: [], env: {} })), diff --git a/packages/maker-core/src/agents/pi/index.ts b/packages/maker-core/src/agents/pi/index.ts index 96e7e445778..10d2cb5877c 100644 --- a/packages/maker-core/src/agents/pi/index.ts +++ b/packages/maker-core/src/agents/pi/index.ts @@ -4211,6 +4211,10 @@ export class PiAgent extends BaseAgent { }; let activeEffortSnapshot = initialEffortSnapshot; let mutableEffort: Effort | null = startupEffort ?? null; + // 已确认的思考开关状态:setThinkingEnabled(false) 只发 off,不动 mutableEffort(off 不是 + // 档位)。路由重建类操作(switch_session/set_model)会把档位重置成模型默认,恢复时需要 + // 知道「用户是否关了思考」,否则会把关掉的思考悄悄重开(等待时间与费用都涨)。 + let mutableThinkingEnabled = true; // 用户明确关掉思考(setThinkingEnabled(false) / 切模显式 thinkingEnabled:false / // 启动 opts.thinkingEnabled === false)的意图标记:无载体切模不得据此悄悄重新开启。 // 只跟随显式意图变化;目标不支持思考而下发 off 不算用户意图,不置位。 @@ -6707,16 +6711,39 @@ export class PiAgent extends BaseAgent { } }; - const assertImageInputSupported = (images: readonly PiPromptImage[]): void => { + const assertImageInputSupported = async (images: readonly PiPromptImage[]): Promise => { if (images.length === 0) return; - const nativeModel = nativeProviderById - .get(mutablePiProviderId) - ?.models.find((candidate) => candidate.id === resolveNativeModelId(mutablePiProviderId, mutableModel)); - const supportsImageInput = - mutablePiProviderId === PI_PROVIDER_ID - ? gatewayImageInputByModel.get(mutableModel) === true - : nativeModel !== undefined && (nativeModel.input ?? ['text', 'image']).includes('image'); - if (supportsImageInput) return; + // 目录未声明 input 时按**支持**图片处理(上游 #4854:未知能力默认放行,不再一律拒收), + // 与本文件其余判定口径一致;本机声明面(override)只用来把「确实不支持」的模型挡下来。 + const supportsNow = (): boolean => { + if (mutablePiProviderId === PI_PROVIDER_ID) { + return gatewayImageInputByModel.get(mutableModel) === true; + } + const nativeModel = nativeProviderById + .get(mutablePiProviderId) + ?.models.find( + (candidate) => candidate.id === resolveNativeModelId(mutablePiProviderId, mutableModel), + ); + return nativeModel !== undefined && (nativeModel.input ?? ['text', 'image']).includes('image'); + }; + // 双向对账必须先于信任快照:声明可能从「支持」被改成「不支持」,此时旧快照仍是 true, + // 先查 supportsNow() 会直接把图片放给一个已声明不支持的模型。对账在判定之前跑 + // (目录未声明 / 与快照一致时只花一次内存查找)。 + // send / steer 不在会话串行链上,这里补链:switch_session / set_model 不得与在途控制 + // RPC(心跳 setModel、compact)交错;switchModel 路径已在链内,故不在那边重复加锁。 + // 无图消息在第一行 return,纯文本发送仍是零成本。 + await runExclusivePiRpc(() => + refreshImageCapabilityOnSwitch(mutableModel, mutablePiProviderId), + ); + // 宿主快照已对齐但子进程还没吃到新清单(回合在跑,重载被延后):本次不得放行 —— 图片 + // 会发给一个仍按旧清单判定的子进程。缓存已对齐,下一轮回合结束/下一次同步点自动生效。 + if (imageCapabilityReloadPending && supportsNow()) { + throw new Error( + '[PI_IMAGE_CAPABILITY_REFRESH_FAILED] 图片输入能力已更新,但子进程要等当前回合结束后才能加载新清单;' + + '请稍后重发这张图(或切一次模型)。', + ); + } + if (supportsNow()) return; throw new PiImageInputUnsupportedError(); }; @@ -6740,9 +6767,55 @@ export class PiAgent extends BaseAgent { ); } throw new Error( - '[PI_CATALOG_RELOAD_UNCONFIRMED] 模型目录重载未确认,已终止本任务。请重新打开任务后再切换模型。', + '[PI_CATALOG_RELOAD_UNCONFIRMED] 模型目录重载未确认,已终止本任务。请重新打开任务后重试。', ); }; + const restoreNativeCatalog = async ( + previousProviders: PiNativeProviderSpec[], + ): Promise => { + // 先写盘再改内存:写失败时磁盘仍是新目录,内存也保持新目录,避免分叉后再抛。 + const written = await this.writeModelsJson( + configHome, + previousProviders, + retainedRuntimeModel, + authProviderId, + { + remote, + fileOps, + contextWindow: ctx.contextWindow || startupContextWindow, + workingContextWindow: ctx.workingContextWindow, + // 任务级预算必须随 settings.json 重写下发:漏传会把逐模型 + // compaction.modelOverrides 按模型自身窗口重算,预算静默失效。 + sessionContextWindowBudget, + piCompactionPct: sessionPiAutoCompactPct, + packages: nativePackagePaths, + disabledSkills: disabledSkillLaunch, + }, + ); + nativeProviders = previousProviders; + nativeProviderById.clear(); + nativeProviderBySourceId.clear(); + for (const spec of previousProviders) { + if (spec.id === PI_PROVIDER_ID) continue; + nativeProviderById.set(spec.id, spec); + nativeProviderBySourceId.set(spec.sourceProviderId ?? spec.id, spec); + } + gatewayApiByModel.clear(); + for (const [key, value] of written.gatewayApiByModel) gatewayApiByModel.set(key, value); + gatewayImageInputByModel.clear(); + for (const [key, value] of written.gatewayImageInputByModel) { + gatewayImageInputByModel.set(key, value); + } + }; + const restoreNativeCatalogOrTerminate = async ( + previousProviders: PiNativeProviderSpec[], + ): Promise => { + try { + await restoreNativeCatalog(previousProviders); + } catch (err) { + await terminateUnconfirmedCatalogReload(err); + } + }; let liveRuntimeSettings: PiNativeRuntimeSettings | undefined; const queryNativeRuntime = async ( operation: 'inspect' | 'refresh', @@ -7195,6 +7268,347 @@ export class PiAgent extends BaseAgent { pendingCurrentDescriptorRefresh = true; if (nextGateway) retainedLiveGatewayModel = nextDescriptor ?? previousGatewayModel; }; + /** + * 图片能力对账的「宿主快照已改、子进程清单未重载」标记。回合在跑时不重建子会话 + * (switch_session 会按 CLI 路由重建 AgentSession,打断在跑的回合),此时只对齐宿主 + * 快照(准入门立即生效),由下一次空闲对账(心跳 / 发图 / 切模)补重载。 + */ + let imageCapabilityReloadPending = false; + /** + * 把 models.json 的新清单交给子进程。与 xAI 热刷新同口径:set_model 不重读 models.json, + * 只有 switch_session → createRuntime → ModelConfig.load 才让子进程加载新清单。 + * 无 sdkSessionId 时无法 switch_session,只保证 host 侧(准入门读的快照)已对齐。 + */ + const reloadImageCapabilityIntoChild = async ( + model: string, + rollbackBaseline?: PiNativeProviderSpec[], + ): Promise => { + const sessionPath = sdkSessionId; + if (!sessionPath) { + imageCapabilityReloadPending = false; + return; + } + let reloaded; + try { + reloaded = await proc.request({ type: 'switch_session', sessionPath }); + } catch (err) { + // reject/超时 = 不知道子进程有没有吃到新 models.json,回滚和放行都可能分叉。 + return await terminateUnconfirmedCatalogReload(err); + } + if (!reloaded.success) { + // 本次对账刚写过目录就回滚回旧快照;若是早先推迟的补重载(没有本次基线),保留 + // pending —— 宿主快照已生效,下一次空闲对账继续重试。 + if (rollbackBaseline) await restoreNativeCatalogOrTerminate(rollbackBaseline); + imageCapabilityReloadPending = rollbackBaseline === undefined; + throw new Error( + `[PI_IMAGE_CAPABILITY_REFRESH_FAILED] 图片输入能力更新未生效:子进程重载被拒(${reloaded.error ?? 'unknown'})。` + + (rollbackBaseline + ? '本次变更已回滚,可重试或切换模型后再试。' + : '稍后会自动重试;若持续失败请重新打开任务。'), + ); + } + // switch_session 按进程启动时的 --provider/--model 重建 AgentSession(与本文件其余 4 处 + // switch_session 同一坑,见 switchModel 内注释):重建后子进程回到 spawn 路由,而宿主与 + // 子代理快照仍以为在当前路由上 —— 同路由 no-op、发图、steer 之后都不会再打 set_model, + // 提示词会发给错误的模型且没有任何报错。 + // 但重放**不能无条件发**:spawn 能靠 custom model id 跑在 Pi 自带目录没有的路由上 + // (如 SuperGrok),对这种路由重发 set_model 反而会被 Pi 拒(见 switchModel 开头同路由 + // no-op 的说明与对应测试)—— 先 get_state 读回,读回就等于当前路由时什么都不做。 + let verifiedModel: { provider?: unknown; id?: unknown } | null | undefined; + try { + const verified = await proc.request({ type: 'get_state' }); + if (!verified.success) throw new Error(`get_state failed: ${verified.error ?? 'unknown'}`); + verifiedModel = ( + verified.data as { model?: { provider?: unknown; id?: unknown } | null } | undefined + )?.model; + } catch (err) { + // 读不到状态 = 不知道子进程在哪条路由上,放行与回滚都可能分叉。 + return await terminateUnconfirmedCatalogReload(err); + } + const routeMatches = + verifiedModel?.provider === mutablePiProviderId && verifiedModel.id === mutableWireModel; + if (!routeMatches) { + let reapplied; + try { + reapplied = await proc.request({ + type: 'set_model', + provider: mutablePiProviderId, + modelId: mutableWireModel, + }); + } catch (err) { + // 请求本身失败 = 变更结果未知。 + return await terminateUnconfirmedCatalogReload(err); + } + if (!reapplied.success) { + // 子进程确实没被切回当前路由:继续用它会静默发到错误的模型上,而保留 pending + // 重试也修不好“已经跑在错路由上”这个事实 —— 终止(fail-closed)。 + return await terminateUnconfirmedCatalogReload( + reapplied.error ?? 'set_model rejected during image capability reload', + ); + } + let reverifiedModel: { provider?: unknown; id?: unknown } | null | undefined; + try { + const reverified = await proc.request({ type: 'get_state' }); + if (!reverified.success) { + throw new Error(`get_state failed: ${reverified.error ?? 'unknown'}`); + } + reverifiedModel = ( + reverified.data as { model?: { provider?: unknown; id?: unknown } | null } | undefined + )?.model; + } catch (err) { + return await terminateUnconfirmedCatalogReload(err); + } + if ( + reverifiedModel?.provider !== mutablePiProviderId || + reverifiedModel.id !== mutableWireModel + ) { + return await terminateUnconfirmedCatalogReload( + `route read-back mismatch (expected ${String(mutablePiProviderId)}/${mutableWireModel}, ` + + `got ${String(reverifiedModel?.provider)}/${String(reverifiedModel?.id)})`, + ); + } + } + // switch_session / set_model 都会把思考档位重置成模型默认(pi CLI 行为,见 switchModel + // 内注释与转录实证)。重载路径不会经过 switchModel 末尾的档位恢复 —— 发图触发的重载 + // 或同路由切模触发的重载早退后,子进程实际跑在默认档而 UI 仍显示原档位。这里把会话 + // 当前档位重新下发;失败只记 warn(与 switchModel 同一纪律:档位救不回也不能让发图/对账失败)。 + if (mutableEffort && activeEffortSnapshot?.includes(mutableEffort)) { + // 关闭开关只发 off、不更新 mutableEffort(见 setThinkingEnabled):恢复时优先还原 + // 已确认的关闭状态,否则重载会把用户关掉的思考悄悄重开。 + const restoreLevel = mutableThinkingEnabled + ? effortToPiThinkingLevel(mutableEffort) + : ('off' as const); + try { + const restore = await proc.request({ + type: 'set_thinking_level', + level: restoreLevel, + }); + if (!restore.success) { + deps.logger.warn('pi: restore thinking level after image capability reload failed', { + level: restoreLevel, + error: restore.error, + }); + } + } catch (err) { + deps.logger.warn('pi: restore thinking level after image capability reload errored', { + level: restoreLevel, + message: err instanceof Error ? err.message : String(err), + }); + } + } + imageCapabilityReloadPending = false; + this.deps.logger.debug('pi image capability refresh reloaded', { + model, + provider: mutablePiProviderId, + }); + }; + /** + * 会话启动后用户可能才在设置里声明图片输入(本机目录 override)。快照是 startSession 一次 + * 性解析的、Pi 的 set_model 又不重读 models.json —— 旧会话里「声明完仍被 + * assertImageInputSupported 拒收」。切模是用户可见的自然同步点:目标模型的**目录声明**与 + * 会话快照不一致时,只对齐该模型的 input(不整体换 providers:活会话的 env/代理 token 是 + * 启动时注入的,整体换会踩 xAI 热刷新已知的坑),原子热写 models.json,再让子进程加载 + * (见 reloadImageCapabilityIntoChild;回合在跑时只对齐宿主快照). + * 心跳会反复下发同路由(见 switchModel 开头的同路由 no-op),所以对账只做一次目录内存查找: + * 无变化零 I/O。目录未声明(undefined)按「默认支持图片」对齐(上游 #4854 的准入语义)—— + * 包括「用户先声明不支持、再改回跟随供应商」时把快照从纯文本恢复回支持。 + * 网关(xd)模型的图片能力不在本机声明面内(设置里也没有该入口),不走此路径。 + */ + const refreshImageCapabilityOnSwitch = async ( + nextModel: string, + nextRequestedProviderId: string | null | undefined, + ): Promise => { + const specProviderId = resolveProviderForModel(nextModel, nextRequestedProviderId); + if (specProviderId === PI_PROVIDER_ID) { + this.deps.logger.debug('pi image capability refresh skipped', { + reason: 'gateway-route', + model: nextModel, + }); + return; + } + const fresh = this.deps.readModelImageInput?.( + resolveSourceProvider(specProviderId), + nextModel, + ); + const specModelId = resolveNativeModelId(specProviderId, nextModel); + const currentSpec = nativeProviderById.get(specProviderId) + ?.models.find((candidate) => candidate.id === specModelId); + // 快照基线口径与准入门一致(上游 #4854):`input` 未声明时按「支持图片」算。 + const hadImage = (currentSpec?.input ?? ['text', 'image']).includes('image'); + // 目录已声明(本机 override,或目录自己标了不支持)→ 声明就是权威,直接对齐。 + // 目录未声明(undefined)时需要活动目录的**最终结论**才能判定:此时 + // 「用户曾声明不支持、刚改回跟随供应商」(应恢复支持) 与「目录自己标 input:['text']」 + // (应保持纯文本) 在声明面上完全一样。拿不到结论就不翻转快照(保守,不猜)。 + const catalogCapability = + fresh === undefined + ? this.deps.readModelCatalogImageCapability?.( + resolveSourceProvider(specProviderId), + nextModel, + ) + : undefined; + const wantsImage = fresh === undefined ? catalogCapability : fresh; + const shouldAlign = wantsImage !== undefined && hadImage !== wantsImage; + if (!shouldAlign && !imageCapabilityReloadPending) { + this.deps.logger.debug('pi image capability refresh skipped', { + reason: fresh === undefined ? 'catalog-undeclared' : 'matches-snapshot', + model: nextModel, + provider: specProviderId, + }); + return; + } + if (shouldAlign) { + // 会话快照里根本没有这条模型行(启动后才新增的 BYOM/目录行):nextProviders 是在旧 + // 快照的 models 数组上 map,写不出这一行 —— 写盘是空操作,却会置 pending 并触发一次 + // 有终止风险的子进程重载,而准入门读的快照始终没变(声明永远不会生效)。显式跳过。 + if (currentSpec === undefined) { + this.deps.logger.warn('pi image capability refresh skipped', { + reason: 'row-not-in-snapshot', + model: nextModel, + provider: specProviderId, + }); + return; + } + // inheritModels 的订阅行只有带 api/catalogAddition 才写进 models.json(见 writeModelsJson + // 的 models 过滤):这类行改 input 落不到子进程,只翻宿主快照会造成 host/child 分叉。 + // 当前目录数据里订阅行都带 api,这里显式挡住未来的例外。 + const providerSpec = nativeProviders.find((candidate) => candidate.id === specProviderId); + if ( + providerSpec?.inheritModels === true && + currentSpec.api === undefined && + currentSpec.catalogAddition !== true + ) { + this.deps.logger.warn('pi image capability refresh skipped', { + reason: 'row-not-materialized', + model: nextModel, + provider: specProviderId, + }); + return; + } + this.deps.logger.info('pi image capability refresh applying', { + model: nextModel, + provider: specProviderId, + wantsImage, + hadImage, + }); + } + + const rollbackBaseline = shouldAlign ? nativeProviders.slice() : undefined; + if (rollbackBaseline) { + const previousProviders = rollbackBaseline; + const nextProviders: PiNativeProviderSpec[] = previousProviders.map((provider) => + provider.id !== specProviderId + ? provider + : { + ...provider, + models: provider.models.map((modelSpec) => + modelSpec.id !== specModelId + ? modelSpec + : { + ...modelSpec, + input: (wantsImage + ? ['text', 'image'] + : ['text']) as Array<'text' | 'image'>, + }, + ), + }, + ); + try { + const written = await this.writeModelsJson( + configHome, + nextProviders, + retainedRuntimeModel, + authProviderId, + { + remote, + fileOps, + contextWindow: ctx.contextWindow || startupContextWindow, + workingContextWindow: ctx.workingContextWindow, + // 任务级预算必须随 settings.json 重写下发:漏传会把逐模型 + // compaction.modelOverrides 按模型自身窗口重算,预算静默失效。 + sessionContextWindowBudget, + piCompactionPct: sessionPiAutoCompactPct, + packages: nativePackagePaths, + disabledSkills: disabledSkillLaunch, + }, + ); + nativeProviders = nextProviders; + nativeProviderById.clear(); + nativeProviderBySourceId.clear(); + for (const spec of nextProviders) { + if (spec.id === PI_PROVIDER_ID) continue; + nativeProviderById.set(spec.id, spec); + nativeProviderBySourceId.set(spec.sourceProviderId ?? spec.id, spec); + } + gatewayApiByModel.clear(); + for (const [key, value] of written.gatewayApiByModel) gatewayApiByModel.set(key, value); + gatewayImageInputByModel.clear(); + for (const [key, value] of written.gatewayImageInputByModel) { + gatewayImageInputByModel.set(key, value); + } + imageCapabilityReloadPending = true; + } catch (err) { + // 原子写保证盘上要么是旧的完整文件要么是新的完整文件;回滚把两种情况都收敛成旧状态, + // 与内存一致(写失败时内存还没换)。 + this.deps.logger.warn('pi image capability refresh persist failed', { + model: nextModel, + provider: specProviderId, + message: err instanceof Error ? err.message : String(err), + }); + await restoreNativeCatalogOrTerminate(previousProviders); + throw new Error( + `[PI_IMAGE_CAPABILITY_REFRESH_FAILED] 图片输入能力更新写入失败,本次变更已回滚:` + + `${err instanceof Error ? err.message : String(err)}。请重试;若持续失败请重新打开任务。`, + ); + } + } + if (!imageCapabilityReloadPending) return; + // 回合运行中不重建子会话:switch_session 会按 CLI 路由重建 AgentSession,把在跑的回合 + // 打断/错配。宿主快照已对齐(准入门立即生效),子进程清单留到下一次空闲对账再加载。 + if (ctx.isStreaming || ctx.pendingHostTurnStartToken) { + this.deps.logger.debug('pi image capability refresh deferred', { + reason: 'turn-running', + model: nextModel, + }); + return; + } + await reloadImageCapabilityIntoChild(nextModel, rollbackBaseline); + }; + + /** + * 思考档位对账:重新读活动目录里该模型**最终**的档位集合,对齐活会话的 + * `activeEffortSnapshot`。 + * + * 为什么必须做:`activeEffortSnapshot` 是 startSession 一次性解析的,Pi 的 set_model 又 + * 不重读 models.json —— 用户在设置里声明档位后,活着的子进程仍是零档位快照,thinking + * 通道开不出来(推理继续漏进正文),只有重开会话才生效。切模是用户可见的自然同步点, + * 与图片能力对账放在同一位置(no-op 早退之前)。 + * + * 只对齐**档位集合**(决定通道是否存在),不代用户改档位记忆:`set_thinking_level` 的 + * 具体档位仍由会话自己的 effort 决定。拿不到结论(undefined)时保持快照不变,不猜。 + */ + const resolveThinkingTiersForSwitch = ( + nextModel: string, + nextRequestedProviderId: string | null | undefined, + ): { tiers: Effort[]; from: readonly Effort[] } | null => { + const specProviderId = resolveProviderForModel(nextModel, nextRequestedProviderId); + if (specProviderId === PI_PROVIDER_ID) { + // 网关路由的档位不在本机声明面内(设置里没有该入口)。 + return null; + } + const declared = this.deps.readModelCatalogThinkingTiers?.( + resolveSourceProvider(specProviderId), + nextModel, + ); + if (!declared) return null; + // 空数组 = 显式「不支持思考」,同样是有效结论(上一轮把 efforts:[] 当成 undefined + // 会让「刚保存的关闭声明」在对账层又退回保留旧快照,关闭不生效)。 + // 不能用「与 activeEffortSnapshot 相同」判空:那是**当前模型**的快照,与目标模型的 + // 子进程状态无关(目标模型在本进程启动时冻结的快照可能还停在旧声明,set_model 后 + // pi 又会重置到目标默认档)。碰巧相同也必须返回结论,否则 preflight 退回冻结快照 + // (声明后带新档切入被误拒)、落定回退同样过期的冻结值。返回结论不会多发 RPC: + // 恢复门仍按落定快照决定是否 set_thinking_level。 + return { tiers: [...declared] as Effort[], from: activeEffortSnapshot ?? [] }; + }; /** * 应用思考开关(调用方必须已在 `runExclusivePiRpc` 队列内)。 * @@ -7232,6 +7646,9 @@ export class PiAgent extends BaseAgent { // 只在真的打开时更新档位记忆:关掉思考不丢档位——档位是用户的独立选择, // 再打开应回到原档位,而不是回落到启动时的档位。 if (wanted) mutableEffort = targetEffort ?? 'xhigh'; + // 已确认的思考开关状态(reloadImageCapabilityIntoChild 的档位恢复靠它区分 + // 「档位在 max 的思考」与「已关闭的思考」)。 + mutableThinkingEnabled = enabled; }; const switchModel = async ( model: string, @@ -7258,6 +7675,27 @@ export class PiAgent extends BaseAgent { // docs/research/pi-native-model-refresh.md 与 docs/dev-rules/pi-harness.md // 「只适配官方 Pi」)。任务级窗口预算因此不走热改:预算变化由宿主判定有效窗口变化后 // 重启引擎(createOpts 重新注入),或在预演里判定需要重建后走既有恢复分支。 +// 上游已把压缩预留的重算/比对交给 queryNativeRuntime + liveRuntimeSettings(本次 + // 306 个提交里重构),本 PR 不再自带 projectedReserve 预检,避免两套口径重复。 + // 思考档位对账必须在同路由 no-op **之前**(与图片能力同一位置):否则用户 + // 「声明完不动模型」永远走那条早返回,活着的子进程仍是启动时的零档位快照。 + // + // **只算不落定**:这里是切模的最早入口,后面还有目录成员校验 / effort 校验 / + // set_model RPC,任何一步失败都会停在旧模型 —— 提前改写快照会让随后的思考开关与 + // effort 校验按「失败目标」的档位执行。落定交给成功路径,失败则完全不动 + // (不猜、不回滚,因为从没改过)。 + const pendingThinkingTiers = resolveThinkingTiersForSwitch(model, requestedProviderId); + if (pendingThinkingTiers) { + deps.logger.info('pi: thinking tiers will realign from the active catalog', { + model, + providerId: resolveProviderForModel(model, requestedProviderId), + from: pendingThinkingTiers.from, + to: pendingThinkingTiers.tiers, + }); + } + // 能力对账必须在同路由 no-op **之前**:心跳与「声明后切回同模型」走的都是那条早返回。 + // 无目录变化时这里只花一次内存查找(见函数注释),心跳不受影响。 + await refreshImageCapabilityOnSwitch(model, requestedProviderId); if (preview.action === 'refresh') await refreshLiveCatalog(model, effectiveProviderId); // Never let a requested native source silently fall through to Cindy's // gateway when a provider disappeared or a model was removed. @@ -7274,9 +7712,17 @@ export class PiAgent extends BaseAgent { wireModel === mutableWireModel; // effort 能力校验必须排在写路由快照**之前**:它会抛错中止本次切换,而快照一旦落盘就 // 指向了新 provider —— 那正是父子路由分叉的形状(upstream #1451 与本 PR 的合并点)。 + // + // 预检用**对账读到的目录结论**(pendingThinkingTiers):用户刚为目标模型声明了新档位、 + // 随即带着新 effort 切模时,启动时冻结的 nextEffortSnapshot 里还没有这一档, + // 会在 RPC 发出前把它误判为不可用、让切换失败。校验只是预检,活动快照 + // 仍只在切模成功后落定(见 routeUnchanged 早退与成功落定两处)。 const nextEffortSnapshot = resolveAppliedEffortSnapshot(provider, model); + const preflightEffortSnapshot = pendingThinkingTiers + ? pendingThinkingTiers.tiers + : nextEffortSnapshot; if (setOpts?.effort) { - assertStartupEffortAllowed(nextEffortSnapshot, setOpts.effort); + assertStartupEffortAllowed(preflightEffortSnapshot, setOpts.effort); } // 远端启动快照保留会话内可切换的全部 native provider,但 SSH reverse-forward // 只为当前实际路由建立。否则用户仅仅登录过 xAI,就会在启动任意 Cindy/BYOM @@ -7313,7 +7759,13 @@ export class PiAgent extends BaseAgent { // 所以这一步落的是**带 pending 标记的**新路由:内容已经就位(证明可写、内容可回滚),但 // 扩展见到 `pending: true` 就拒绝派发。等待窗口里一个子进程都起不来,既不会用未确认的新 // 路由、也不会用与父不一致的旧路由;确认后再清掉标记放行。 +// 上游把 no-op 判定改为只依赖 routeUnchanged + preview.action,不再比较 + // oldReserve/targetReserve(压缩预留由 queryNativeRuntime 接管)。沿用上游判定, + // 保留本 PR 的思考档位落定点:同路由 no-op 不发 set_model,不会失败,是安全落定点。 if (routeUnchanged && preview.action === 'hot' && !pendingCurrentDescriptorRefresh) { + // 同路由 no-op 不会失败(不发 set_model),所以这里就是安全的落定点:否则 + // 「声明完不动模型」永远对不上档位快照。 + if (pendingThinkingTiers) activeEffortSnapshot = pendingThinkingTiers.tiers; if (!(await writeSubagentRuntimeFile({ model: wireModel, provider }))) { deps.logger.warn('pi: same-route setModel could not refresh subagent snapshot', { model, provider }); } @@ -7446,7 +7898,11 @@ export class PiAgent extends BaseAgent { pendingCurrentDescriptorRefresh = false; mutableWireModel = wireModel; mutablePiProviderId = provider; - activeEffortSnapshot = nextEffortSnapshot; + // 成功落定:优先用本次对账读到的目录结论(用户在会话期间声明过), + // 否则用本次切模解析出的档位。失败路径走不到这里,快照保持旧模型的值。 + activeEffortSnapshot = pendingThinkingTiers + ? pendingThinkingTiers.tiers + : nextEffortSnapshot; mutableProviderId = effectiveProviderId; const explicitThinking = setOpts && typeof setOpts.thinkingEnabled === 'boolean' ? setOpts.thinkingEnabled : undefined; @@ -7466,11 +7922,18 @@ export class PiAgent extends BaseAgent { // 都会掉回默认档,表现为「推理强度自动变低」。目标档 = 显式 effort > 会话原档位 // (mutableEffort);新模型不支持(含未声明档位)时保持 pi 默认,与能力收敛一致。 const restoredEffort = setOpts?.effort ?? mutableEffort; + // 恢复门已确认的档位:尾部归一化段不再按过期快照重发/降档(2026-10-23 Greptile: + // 恢复门按落定快照发 max、归一化段按冻结快照改回首档的自相矛盾)。 + let restoredConfirmed: Effort | null = null; + // 恢复门必须用**本次落定的** activeEffortSnapshot(含用户新声明),不能用启动时冻结的 + // nextEffortSnapshot:目标模型启动时只有 low、用户随后声明了 max 并带 effort=max 切入时, + // 预检已放行、set_model 已成功,若这里仍按冻结快照判断就会静默不发 set_thinking_level, + // 实际跑在 Pi 自己的默认档上(切模请求本身没应用用户显式指定的档位)。 if ( !keepThinkingOff && restoredEffort && - nextEffortSnapshot && nextEffortSnapshot.length > 0 && - nextEffortSnapshot.includes(restoredEffort) + activeEffortSnapshot && activeEffortSnapshot.length > 0 && + activeEffortSnapshot.includes(restoredEffort) ) { // 恢复请求失败(被拒或超时/链路抛错)都不能让 setModel 失败:模型路由此刻已确认, // 抛错只会跳过下面的子代理快照收尾(pending 卡死委派),档位本身却救不回来。 @@ -7481,8 +7944,11 @@ export class PiAgent extends BaseAgent { }); if (restore.success) { // mutableEffort 只跟随**已确认生效**的档位:它是对外汇报(getEffort)与后续 - // 切换恢复的依据;被 Pi 拒绝的目标档不能当成已生效值。 + // 切换恢复的依据;被 Pi 拒绝的目标档不能当成已生效值。应用具体档位同时意味着 + // 思考是开着的 —— 关闭状态到此被覆盖,后续 reload 恢复的是这个档位而不是 off。 mutableEffort = restoredEffort; + mutableThinkingEnabled = true; + restoredConfirmed = restoredEffort; } else { deps.logger.warn('pi: restore thinking level after model switch failed', { effort: restoredEffort, @@ -7499,6 +7965,10 @@ export class PiAgent extends BaseAgent { autoReviewDecisionCache.clear(); autoReviewUnavailableNotice.reset(); autoReviewConfirmUndeliveredNotice.reset(); + // 窗口/预留复核:采用上游新版实现(get_state 复核 verifiedWindow + + // reserveForWindow),它取代了旧版「读 set_model 响应 + switch_session 重载」: + // 旧路径依赖 set_model 响应里的窗口,而上游已把该响应降为不可信(目录与运行时 + // 都可能与它分叉),复核必须以子进程自报的 get_state 为准。 // The parent route and target-window settings are now confirmed. Clear the // pending marker last; failure keeps subagent delegation disabled but does // not misreport the parent switch itself. @@ -7554,9 +8024,12 @@ export class PiAgent extends BaseAgent { model, provider, }); - } else if (targetReasoning === true) { - const usable = nextEffortSnapshot && nextEffortSnapshot.length > 0 - ? nextEffortSnapshot + } else if (targetReasoning === true) { + // 与恢复门同口径:用本次落定的 activeEffortSnapshot(含会话期间的新声明), + // 不能用启动冻结的 nextEffortSnapshot——目标模型刚声明的新档会被这里按旧快照 + // 降档覆盖(恢复门发 max、归一化又改回首档)。 + const usable = activeEffortSnapshot && activeEffortSnapshot.length > 0 + ? activeEffortSnapshot : undefined; // 用户明确要开思考、目标也支持:即使这次 selection 没带档位(BYOM/无 effort // 目录的会话),也要按启动快照收敛——不能什么都不发,否则上一个模型的 off 会 @@ -7602,35 +8075,44 @@ export class PiAgent extends BaseAgent { } } if (level) { - // 拒绝可能是瞬时(例如 Pi 正在处理前一条档位请求):重试一次再放弃。 - // 仍失败只告警:模型路由已经确认,抛出会让上层回滚它。 - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - const resp = await proc.request({ type: 'set_thinking_level', level }); - if (resp.success) { - if (appliedEffort) mutableEffort = appliedEffort; - // 意图标记只在 RPC 成功后更新:显式意图才算用户意图(显式 true+目标 - // 不支持思考而下发的 off 不是用户意图,标记仍为开);失败沿用原标记。 - if (explicitThinking === true) thinkingExplicitlyOff = false; - else if (explicitThinking === false) thinkingExplicitlyOff = true; - break; - } - if (attempt === 1) { - deps.logger.warn('pi: set_thinking_level rejected after model switch', { - model, - provider, - level, - error: resp.error, - }); - } - } catch (err) { - if (attempt === 1) { - deps.logger.warn('pi: set_thinking_level failed after model switch', { - model, - provider, - level, - message: err instanceof Error ? err.message : String(err), - }); + if (restoredConfirmed && appliedEffort === restoredConfirmed) { + // 恢复门刚按落定快照确认过同档:只省重复 RPC,确认成功后的状态更新仍要做—— + // 显式 thinkingEnabled 载体必须清掉 thinkingExplicitlyOff,否则随后的无载体 + // 切模仍按「用户明确关过思考」跳过恢复,刚重新开启的档位被误判回关闭。 + if (appliedEffort) mutableEffort = appliedEffort; + if (explicitThinking === true) thinkingExplicitlyOff = false; + else if (explicitThinking === false) thinkingExplicitlyOff = true; + } else { + // 拒绝可能是瞬时(例如 Pi 正在处理前一条档位请求):重试一次再放弃。 + // 仍失败只告警:模型路由已经确认,抛出会让上层回滚它。 + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const resp = await proc.request({ type: 'set_thinking_level', level }); + if (resp.success) { + if (appliedEffort) mutableEffort = appliedEffort; + // 意图标记只在 RPC 成功后更新:显式意图才算用户意图(显式 true+目标 + // 不支持思考而下发的 off 不是用户意图,标记仍为开);失败沿用原标记。 + if (explicitThinking === true) thinkingExplicitlyOff = false; + else if (explicitThinking === false) thinkingExplicitlyOff = true; + break; + } + if (attempt === 1) { + deps.logger.warn('pi: set_thinking_level rejected after model switch', { + model, + provider, + level, + error: resp.error, + }); + } + } catch (err) { + if (attempt === 1) { + deps.logger.warn('pi: set_thinking_level failed after model switch', { + model, + provider, + level, + message: err instanceof Error ? err.message : String(err), + }); + } } } } @@ -7728,7 +8210,9 @@ export class PiAgent extends BaseAgent { let { text, images } = await buildPiPrompt(message, { remote }); const pinnedSkill = sendOpts?.[PINNED_SKILL_INVOCATION]; rejectIfCancelled(sendOpts, 'send'); - assertImageInputSupported(images); + await assertImageInputSupported(images); + // 对账可能 await 过(仅目录已声明的失败路径),期间 signal 可能被撤:投递前再查一次。 + rejectIfCancelled(sendOpts, 'send'); setAutoReviewIntent(appendAutoReviewUserIntent(priorAutoReviewIntent(), message.content, sendOpts), { authority: autoReviewContext() }); reviewIntentUpdated = true; const applyRequestedPlanMode = async () => { @@ -8087,7 +8571,9 @@ export class PiAgent extends BaseAgent { let { text, images } = await buildPiPrompt(message, { remote }); rejectIfCancelled(sendOpts, 'steer'); rejectIfUnavailable(); - assertImageInputSupported(images); + await assertImageInputSupported(images); + // 同上:对账 await 后重新确认未被取消。 + rejectIfCancelled(sendOpts, 'steer'); setAutoReviewIntent(appendAutoReviewUserIntent(priorAutoReviewIntent(), message.content, sendOpts)); // A steered channel message can add confirmation requirements to the // running turn, but must not remove the current sender's restrictions. @@ -8381,13 +8867,27 @@ export class PiAgent extends BaseAgent { async setEffort(effort: Effort): Promise { if (reviewMode) return; assertStartupEffortAllowed(activeEffortSnapshot, effort); - if (activeEffortSnapshot?.length === 0) return; + if (activeEffortSnapshot?.length === 0) { + // 防御:该模型在本次启动快照里没有任何思考档位(目录声明不支持思考)。 + // 静默 return 会让调用方以为「已应用」;而模型实际仍可能推理,推理文本只能走 + // content 通道、被当成普通正文渲染。留一行 warn,让这类目录错报能在日志里定位, + // 而不是只能翻库看消息构成。 + deps.logger.warn('pi set_thinking_level skipped: model declares no thinking tiers', { + model: opts.model, + providerId: opts.providerId ?? null, + requestedEffort: effort, + }); + return; + } const resp = await runExclusivePiRpc(() => proc.request({ type: 'set_thinking_level', level: effortToPiThinkingLevel(effort), })); if (!resp.success) throw new Error(`pi set_thinking_level failed: ${resp.error ?? 'unknown'}`); + // 应用具体档位同时意味着思考开着:覆盖可能的关闭记录,否则后续 reload 恢复会把 + // 刚应用的档位用 off 关掉。 mutableEffort = effort; + mutableThinkingEnabled = true; }, getEffort() { @@ -8400,6 +8900,7 @@ export class PiAgent extends BaseAgent { // 切模进行中用户关闭思考时,若这里不排队直接发,切模收尾仍会按更早的 // thinkingEnabled:true 载体重新下发开启并清掉关闭标记——用户看到关闭成功、 // 实际被覆盖(2026-10-17 Greptile P1)。串行后意图按时间序线性化,后发者胜。 + // applyThinkingEnabled 已含「目标模型无思考档位时下发 off + 不动档位记忆」的防御。 await runExclusivePiRpc(() => applyThinkingEnabled(enabled)); },