-
Notifications
You must be signed in to change notification settings - Fork 2.6k
fix(web-shell): encode vision model picker selection & polish dispatch #6236
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f4cf903
b22641c
f2d5079
100d12e
05c6c71
47fee4b
384c10a
0eedf4b
4ad0e11
fd8e83a
b7898e5
722a952
f710b40
7018f0d
a5ef400
86d5a15
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<ModelDialogMode, string> = { | ||
| 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 = (() => { | ||
|
AmariahAK marked this conversation as resolved.
|
||
| 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<ModelDialogMode, (id: string) => 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,33 +3505,25 @@ export function App({ | |
| )} | ||
| {modelDialogMode && ( | ||
| <DialogShell | ||
| title={ | ||
| modelDialogMode === 'fast' | ||
| ? t('model.setFast') | ||
| : modelDialogMode === 'voice' | ||
| ? t('model.setVoice') | ||
| : modelDialogMode === 'vision' | ||
| ? t('model.setVision') | ||
| : t('model.select') | ||
| } | ||
| title={t(MODE_TITLE_KEY[modelDialogMode])} | ||
| size="lg" | ||
| onClose={() => setModelDialogMode(null)} | ||
| > | ||
| <ModelDialog | ||
| mode={modelDialogMode} | ||
| models={modelDialogMode === 'voice' ? voiceModels : undefined} | ||
| currentModelId={ | ||
| modelDialogMode === 'voice' ? currentVoiceModel : undefined | ||
| modelDialogMode === 'voice' | ||
| ? currentVoiceModel | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The This PR added — qwen3.7-max via Qwen Code /review |
||
| : 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); | ||
| }} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 { | ||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion]
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||
| // 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; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.