Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 54 additions & 20 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -1650,6 +1663,19 @@ export function App({
)?.values.effective;
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
})();
const currentVisionModel = (() => {
Comment thread
AmariahAK marked this conversation as resolved.
Comment thread
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);
Expand Down Expand Up @@ -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');
});
Expand All @@ -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')),
);
},
Expand All @@ -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))
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The currentModelId ternary has no 'fast' branch. When modelDialogMode === 'fast', it falls through to undefined, and ModelDialog falls back to connection.currentModel (the main chat model) — highlighting the wrong model in the fast picker.

This PR added currentVisionModel (great!) but missed currentFastModel. Consider adding it following the same derivation pattern.

— 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);
}}
Expand Down
6 changes: 6 additions & 0 deletions packages/web-shell/client/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 文件。',
Expand Down
147 changes: 147 additions & 0 deletions packages/web-shell/client/utils/modelEncoding.test.ts
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);
});
});
33 changes: 33 additions & 0 deletions packages/web-shell/client/utils/modelEncoding.ts
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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] decodeVisionModelForPicker doesn't handle the \0baseUrl suffix that the CLI stores for vision models with custom endpoints. In modelCommand.ts:590-596, the CLI constructs authType:modelId\0baseUrl when the matched model has a baseUrl. This decoder splits on the first : and produces a corrupted ACP string like modelId\0https://endpoint(authType), which won't match any picker model ID — the "current" highlight silently fails for custom-endpoint vision models.

Suggested change
export function decodeVisionModelForPicker(storedValue: string): string {
export function decodeVisionModelForPicker(storedValue: string): string {
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;
}

— 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;
}
Loading