diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 7c214d39e88..217886a127d 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -97,6 +97,11 @@ import { filterModelSwitchMessages } from './utils/modelSwitchMessages'; import { decideEscapeIntent } from './utils/escapeIntent'; import type { SkillInfo } from './completions/slashCompletion'; import { collectSystemInfo } from './utils/systemInfo'; +import { + decodeVisionModelForPicker, + encodeVisionModelForSetting, + extractBareModelId, +} from './utils/modelEncoding'; import { appendOrDeferLocalUserMessage } from './utils/localCommandQueue'; import { QueuedPromptDisplay } from './components/QueuedPromptDisplay'; import { useQueuedPrompts } from './hooks/useQueuedPrompts'; @@ -218,6 +223,14 @@ const COMPACT_MODE_SETTING_KEY = 'ui.compactMode'; const HIDE_TIPS_SETTING_KEY = 'ui.hideTips'; const HIDDEN_COMPOSER_MODEL_IDS = new Set(['coder-model(qwen-oauth)']); +/** Maps each ModelDialogMode to its i18n title key — single source of truth. */ +const MODE_TITLE_KEY: Record = { + main: 'model.select', + fast: 'model.setFast', + voice: 'model.setVoice', + vision: 'model.setVision', +}; + function isVisibleComposerModel(model: { id: string }): boolean { return !HIDDEN_COMPOSER_MODEL_IDS.has(model.id); } @@ -1650,6 +1663,19 @@ export function App({ )?.values.effective; return typeof value === 'string' && value.trim() ? value.trim() : undefined; })(); + const currentVisionModel = (() => { + const value = workspaceSettings.find( + (setting) => setting.key === 'visionModel', + )?.values.effective; + if (typeof value !== 'string' || !value.trim()) return undefined; + return decodeVisionModelForPicker(value.trim()); + })(); + const currentFastModel = (() => { + const value = workspaceSettings.find( + (setting) => setting.key === 'fastModel', + )?.values.effective; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + })(); const shellOutputMaxLines = resolveShellOutputMaxLines(workspaceSettings); const [compactMode, setCompactMode] = useState(false); const compactModeRef = useRef(compactMode); @@ -3304,6 +3330,9 @@ export function App({ blockLocalCommandDuringTurn(); return; } + // Model IDs from the picker arrive as bare model IDs (baseModelId), not + // ACP format. The model picker strips the (authType) suffix before + // calling this handler. sendPrompt(`/model --fast ${modelId}`).catch((error: unknown) => { reportError(error, 'Failed to switch fast model'); }); @@ -3313,7 +3342,10 @@ export function App({ const handleVoiceModelSelect = useCallback( (modelId: string) => { - setWorkspaceSetting('workspace', 'voiceModel', modelId).catch( + // Model IDs from the voice picker arrive as bare model IDs (baseModelId), + // not ACP format. extractVoiceModels() sets id to the baseModelId. + const bareModelId = extractBareModelId(modelId); + setWorkspaceSetting('workspace', 'voiceModel', bareModelId).catch( (error: unknown) => reportError(error, t('model.setVoice')), ); }, @@ -3322,13 +3354,23 @@ export function App({ const handleVisionModelSelect = useCallback( (modelId: string) => { - setWorkspaceSetting('workspace', 'visionModel', modelId).catch( + // Model IDs from the picker arrive in ACP format: `modelId(authType)`. + // Core's resolveVisionModelSelection() expects `authType:modelId`. + const encoded = encodeVisionModelForSetting(modelId); + setWorkspaceSetting('workspace', 'visionModel', encoded).catch( (error: unknown) => reportError(error, t('model.setVision')), ); }, [reportError, setWorkspaceSetting, t], ); + const modelHandlers: Record void> = { + main: handleModelSelect, + fast: handleFastModelSelect, + voice: handleVoiceModelSelect, + vision: handleVisionModelSelect, + }; + const commands = useMemo(() => { const skillNames = new Set(connection.skills ?? []); return mergeCommands(connection.commands ?? [], getLocalCommands(t)) @@ -3463,15 +3505,7 @@ export function App({ )} {modelDialogMode && ( setModelDialogMode(null)} > @@ -3479,17 +3513,17 @@ export function App({ mode={modelDialogMode} models={modelDialogMode === 'voice' ? voiceModels : undefined} currentModelId={ - modelDialogMode === 'voice' ? currentVoiceModel : undefined + modelDialogMode === 'voice' + ? currentVoiceModel + : modelDialogMode === 'vision' + ? currentVisionModel + : modelDialogMode === 'fast' + ? currentFastModel + : undefined } onSelect={(modelId) => { - if (modelDialogMode === 'fast') { - handleFastModelSelect(modelId); - } else if (modelDialogMode === 'voice') { - handleVoiceModelSelect(modelId); - } else if (modelDialogMode === 'vision') { - handleVisionModelSelect(modelId); - } else { - handleModelSelect(modelId); + if (modelDialogMode) { + modelHandlers[modelDialogMode](modelId); } setModelDialogMode(null); }} diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index c2c0280cb8e..2c55f3b8b48 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1216,6 +1216,9 @@ const EN: Messages = { 'Frontend-only chat content width. Stored in this browser.', 'settings.option.ui.chatWidth.1000': 'Regular', 'settings.option.ui.chatWidth.wide': 'Ultra wide', + 'settings.label.visionModel': 'Vision Model', + 'settings.description.visionModel': + 'Image-capable model used as the vision bridge. Leave empty to auto-select.', 'welcome.changeModel': '(/model to change)', 'welcome.defaultModel': 'unknown model', 'welcome.modeHint': 'Shift+Tab or /approval-mode', @@ -2456,6 +2459,9 @@ const ZH: Messages = { 'settings.label.fastModel': '快速模型', 'settings.description.fastModel': '用于生成提示建议和推测执行的模型。留空则使用主模型。较小/更快的模型(例如 qwen3-coder-flash)可以降低延迟和成本。', + 'settings.label.visionModel': '视觉模型', + 'settings.description.visionModel': + '用于视觉桥接的图像能力模型。留空则自动选择。', 'settings.label.context.fileFiltering.respectGitIgnore': '遵守 .gitignore', 'settings.description.context.fileFiltering.respectGitIgnore': '搜索时遵守 .gitignore 文件。', diff --git a/packages/web-shell/client/utils/modelEncoding.test.ts b/packages/web-shell/client/utils/modelEncoding.test.ts new file mode 100644 index 00000000000..a493252d370 --- /dev/null +++ b/packages/web-shell/client/utils/modelEncoding.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import { + encodeVisionModelForSetting, + extractBareModelId, + decodeVisionModelForPicker, +} from './modelEncoding'; + +describe('encodeVisionModelForSetting', () => { + it('encodes standard ACP format', () => { + expect(encodeVisionModelForSetting('qwen-max(qwen-oauth)')).toBe( + 'qwen-oauth:qwen-max', + ); + }); + + it('passes through non-ACP format unchanged', () => { + expect(encodeVisionModelForSetting('plain-model')).toBe('plain-model'); + }); + + it('passes through empty parens unchanged', () => { + expect(encodeVisionModelForSetting('model()')).toBe('model()'); + }); + + it('passes through colon-bearing IDs unchanged', () => { + expect(encodeVisionModelForSetting('openai:gpt-4o')).toBe('openai:gpt-4o'); + }); + + it('handles nested parentheses by matching outermost ACP pattern', () => { + // The regex matches the outermost group: modelId(authType) + expect(encodeVisionModelForSetting('model(name)(extra)')).toBe( + 'extra:model(name)', + ); + }); + + it('passes through already-encoded colon format unchanged', () => { + // If someone somehow passes an already-encoded authType:modelId, + // it should be left alone — not double-encoded. + expect(encodeVisionModelForSetting('qwen-oauth:qwen-vl-max')).toBe( + 'qwen-oauth:qwen-vl-max', + ); + }); + + it('passes through malformed — bare authType only', () => { + // '(authType)' has no modelId before the parens and no text + // before the opening paren that can serve as group 1, so the + // regex won't match and we get the input back unchanged. + expect(encodeVisionModelForSetting('(authType)')).toBe('(authType)'); + }); + + it('passes through malformed — unclosed paren', () => { + expect(encodeVisionModelForSetting('modelId(')).toBe('modelId('); + }); + + it('passes through malformed — double-parens inside', () => { + expect(encodeVisionModelForSetting('a((b))')).toBe('a((b))'); + }); + + it('passes through empty string unchanged', () => { + expect(encodeVisionModelForSetting('')).toBe(''); + }); +}); + +describe('extractBareModelId', () => { + it('extracts bare model ID from ACP format', () => { + expect(extractBareModelId('qwen-max(qwen-oauth)')).toBe('qwen-max'); + }); + + it('passes through non-ACP format unchanged', () => { + expect(extractBareModelId('plain-model')).toBe('plain-model'); + }); + + it('passes through empty parens unchanged', () => { + // The regex requires at least one char in each capture group, + // so '()' does not match and passes through. + expect(extractBareModelId('()')).toBe('()'); + }); +}); + +describe('decodeVisionModelForPicker', () => { + it('decodes authType:modelId back to ACP format', () => { + expect(decodeVisionModelForPicker('qwen-oauth:qwen-max')).toBe( + 'qwen-max(qwen-oauth)', + ); + }); + + it('handles colon-bearing model IDs — splits on first colon only', () => { + expect(decodeVisionModelForPicker('openai:gpt-4o:online')).toBe( + 'gpt-4o:online(openai)', + ); + }); + + it('passes through values without a colon', () => { + expect(decodeVisionModelForPicker('plain-model')).toBe('plain-model'); + }); + + it('passes through ACP-formatted values unchanged', () => { + expect(decodeVisionModelForPicker('qwen-max(qwen-oauth)')).toBe( + 'qwen-max(qwen-oauth)', + ); + }); + + it('passes through empty string unchanged', () => { + expect(decodeVisionModelForPicker('')).toBe(''); + }); + + it('passes through leading-colon malformed input', () => { + // colonIdx === 0, which is not > 0, so passthrough + expect(decodeVisionModelForPicker(':modelId')).toBe(':modelId'); + }); + + it('strips \\0baseUrl suffix before decoding', () => { + expect( + decodeVisionModelForPicker( + 'qwen-oauth:qwen-vl-max\0https://api.example.com', + ), + ).toBe('qwen-vl-max(qwen-oauth)'); + }); + + it('handles colon-bearing ID with \\0baseUrl suffix', () => { + expect( + decodeVisionModelForPicker( + 'openai:gpt-4o:online\0https://api.openai.com', + ), + ).toBe('gpt-4o:online(openai)'); + }); + + it('passes through bare ID with \\0baseUrl suffix but no colon', () => { + expect( + decodeVisionModelForPicker('plain-model\0https://api.example.com'), + ).toBe('plain-model'); + }); +}); + +describe('round-trip: encode then decode', () => { + it('preserves the original ACP format', () => { + const original = 'qwen-vl-max(qwen-oauth)'; + const encoded = encodeVisionModelForSetting(original); + const decoded = decodeVisionModelForPicker(encoded); + expect(decoded).toBe(original); + }); + + it('preserves colon-bearing IDs after round-trip', () => { + const original = 'gpt-4o:new-model(openai)'; + const encoded = encodeVisionModelForSetting(original); + const decoded = decodeVisionModelForPicker(encoded); + expect(decoded).toBe(original); + }); +}); diff --git a/packages/web-shell/client/utils/modelEncoding.ts b/packages/web-shell/client/utils/modelEncoding.ts new file mode 100644 index 00000000000..e5748c73b81 --- /dev/null +++ b/packages/web-shell/client/utils/modelEncoding.ts @@ -0,0 +1,33 @@ +/** + * Encodes a model ID from ACP format (modelId(authType)) to storage format + * (authType:modelId). Core's resolveVisionModelSelection() expects this format. + * If the input is not in ACP format, returns it unchanged. + */ +export function encodeVisionModelForSetting(modelId: string): string { + const match = modelId.match(/^(.+)\(([^()]+)\)$/); + return match ? `${match[2]}:${match[1]}` : modelId; +} + +export function extractBareModelId(modelId: string): string { + const match = modelId.match(/^(.+)\(([^()]+)\)$/); + return match ? match[1] : modelId; +} + +/** + * Decodes a stored model ID from authType:modelId format back to ACP format + * (modelId(authType)). Used for picker comparison where model IDs are in ACP + * format. Splits on the first colon — safe for colon-bearing model IDs + * (e.g., 'openai:gpt-4o:online' → 'gpt-4o:online(openai)'). + * If the input has no colon, returns it unchanged. + */ +export function decodeVisionModelForPicker(storedValue: string): string { + // CLI stores custom-endpoint vision models as authType:modelId\0baseUrl. + // Strip the \0 suffix before decoding to ACP format. + const nullIdx = storedValue.indexOf('\0'); + const selector = nullIdx >= 0 ? storedValue.slice(0, nullIdx) : storedValue; + const colonIdx = selector.indexOf(':'); + if (colonIdx > 0) { + return `${selector.slice(colonIdx + 1)}(${selector.slice(0, colonIdx)})`; + } + return selector; +}