qwen-code/packages/cli/src/ui/components/ModelDialog.tsx
tanzhenxin 849bdb0dbe fix(i18n): add translations for modality and context window display
- Add i18n keys for modality types and status labels
- Update ModelDialog to use t() for user-facing strings

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-28 15:33:55 +08:00

460 lines
14 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import {
AuthType,
ModelSlashCommandEvent,
logModelSlashCommand,
MAINLINE_CODER_MODEL,
type AvailableModel as CoreAvailableModel,
type ContentGeneratorConfig,
type InputModalities,
} from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { t } from '../../i18n/index.js';
function formatModalities(modalities?: InputModalities): string {
if (!modalities) return t('text-only');
const parts: string[] = [];
if (modalities.image) parts.push(t('image'));
if (modalities.pdf) parts.push(t('pdf'));
if (modalities.audio) parts.push(t('audio'));
if (modalities.video) parts.push(t('video'));
if (parts.length === 0) return t('text-only');
return `${t('text')} · ${parts.join(' · ')}`;
}
interface ModelDialogProps {
onClose: () => void;
}
function maskApiKey(apiKey: string | undefined): string {
if (!apiKey) return `(${t('not set')})`;
const trimmed = apiKey.trim();
if (trimmed.length === 0) return `(${t('not set')})`;
if (trimmed.length <= 6) return '***';
const head = trimmed.slice(0, 3);
const tail = trimmed.slice(-4);
return `${head}${tail}`;
}
function persistModelSelection(
settings: ReturnType<typeof useSettings>,
modelId: string,
): void {
const scope = getPersistScopeForModelSelection(settings);
settings.setValue(scope, 'model.name', modelId);
}
function persistAuthTypeSelection(
settings: ReturnType<typeof useSettings>,
authType: AuthType,
): void {
const scope = getPersistScopeForModelSelection(settings);
settings.setValue(scope, 'security.auth.selectedType', authType);
}
interface HandleModelSwitchSuccessParams {
settings: ReturnType<typeof useSettings>;
uiState: UIState | null;
after: ContentGeneratorConfig | undefined;
effectiveAuthType: AuthType | undefined;
effectiveModelId: string;
isRuntime: boolean;
}
function handleModelSwitchSuccess({
settings,
uiState,
after,
effectiveAuthType,
effectiveModelId,
isRuntime,
}: HandleModelSwitchSuccessParams): void {
persistModelSelection(settings, effectiveModelId);
if (effectiveAuthType) {
persistAuthTypeSelection(settings, effectiveAuthType);
}
const baseUrl = after?.baseUrl ?? t('(default)');
const maskedKey = maskApiKey(after?.apiKey);
uiState?.historyManager.addItem(
{
type: 'info',
text:
`authType: ${effectiveAuthType ?? `(${t('none')})`}` +
`\n` +
`Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}` +
`\n` +
`Base URL: ${baseUrl}` +
`\n` +
`API key: ${maskedKey}`,
},
Date.now(),
);
}
function formatContextWindow(size?: number): string {
if (!size) return `(${t('unknown')})`;
return `${size.toLocaleString('en-US')} tokens`;
}
function DetailRow({
label,
value,
}: {
label: string;
value: React.ReactNode;
}): React.JSX.Element {
return (
<Box>
<Box minWidth={16} flexShrink={0}>
<Text color={theme.text.secondary}>{label}:</Text>
</Box>
<Box flexGrow={1} flexDirection="row" flexWrap="wrap">
<Text>{value}</Text>
</Box>
</Box>
);
}
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext);
const uiState = useContext(UIStateContext);
const settings = useSettings();
// Local error state for displaying errors within the dialog
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [highlightedValue, setHighlightedValue] = useState<string | null>(null);
const authType = config?.getAuthType();
const availableModelEntries = useMemo(() => {
const allModels = config ? config.getAllConfiguredModels() : [];
// Separate runtime models from registry models
const runtimeModels = allModels.filter((m) => m.isRuntimeModel);
const registryModels = allModels.filter((m) => !m.isRuntimeModel);
// Group registry models by authType
const modelsByAuthTypeMap = new Map<AuthType, CoreAvailableModel[]>();
for (const model of registryModels) {
const authType = model.authType;
if (!modelsByAuthTypeMap.has(authType)) {
modelsByAuthTypeMap.set(authType, []);
}
modelsByAuthTypeMap.get(authType)!.push(model);
}
// Fixed order: qwen-oauth first, then others in a stable order
const authTypeOrder: AuthType[] = [
AuthType.QWEN_OAUTH,
AuthType.USE_OPENAI,
AuthType.USE_ANTHROPIC,
AuthType.USE_GEMINI,
AuthType.USE_VERTEX_AI,
];
// Filter to only include authTypes that have registry models and maintain order
const availableAuthTypes = new Set(modelsByAuthTypeMap.keys());
const orderedAuthTypes = authTypeOrder.filter((t) =>
availableAuthTypes.has(t),
);
// Build ordered list: runtime models first, then registry models grouped by authType
const result: Array<{
authType: AuthType;
model: CoreAvailableModel;
isRuntime?: boolean;
snapshotId?: string;
}> = [];
// Add all runtime models first
for (const runtimeModel of runtimeModels) {
result.push({
authType: runtimeModel.authType,
model: runtimeModel,
isRuntime: true,
snapshotId: runtimeModel.runtimeSnapshotId,
});
}
// Add registry models grouped by authType
for (const t of orderedAuthTypes) {
for (const model of modelsByAuthTypeMap.get(t) ?? []) {
result.push({ authType: t, model, isRuntime: false });
}
}
return result;
}, [config]);
const MODEL_OPTIONS = useMemo(
() =>
availableModelEntries.map(
({ authType: t2, model, isRuntime, snapshotId }) => {
// Runtime models use snapshotId directly (format: $runtime|${authType}|${modelId})
const value =
isRuntime && snapshotId ? snapshotId : `${t2}::${model.id}`;
const title = (
<Text>
<Text
bold
color={isRuntime ? theme.status.warning : theme.text.accent}
>
[{t2}]
</Text>
<Text>{` ${model.label}`}</Text>
{isRuntime && (
<Text color={theme.status.warning}> (Runtime)</Text>
)}
</Text>
);
// Include runtime indicator in description
let description = model.description || '';
if (isRuntime) {
description = description
? `${description} (Runtime)`
: 'Runtime model';
}
return {
value,
title,
description,
key: value,
};
},
),
[availableModelEntries],
);
const preferredModelId = config?.getModel() || MAINLINE_CODER_MODEL;
// Check if current model is a runtime model
// Runtime snapshot ID is already in $runtime|${authType}|${modelId} format
const activeRuntimeSnapshot = config?.getActiveRuntimeModelSnapshot?.();
const preferredKey = activeRuntimeSnapshot
? activeRuntimeSnapshot.id
: authType
? `${authType}::${preferredModelId}`
: '';
useKeypress(
(key) => {
if (key.name === 'escape') {
onClose();
}
},
{ isActive: true },
);
const initialIndex = useMemo(() => {
const index = MODEL_OPTIONS.findIndex(
(option) => option.value === preferredKey,
);
return index === -1 ? 0 : index;
}, [MODEL_OPTIONS, preferredKey]);
const handleHighlight = useCallback((value: string) => {
setHighlightedValue(value);
}, []);
const highlightedEntry = useMemo(() => {
const key = highlightedValue ?? preferredKey;
return availableModelEntries.find(
({ authType: t2, model, isRuntime, snapshotId }) => {
const v = isRuntime && snapshotId ? snapshotId : `${t2}::${model.id}`;
return v === key;
},
);
}, [highlightedValue, preferredKey, availableModelEntries]);
const handleSelect = useCallback(
async (selected: string) => {
setErrorMessage(null);
let after: ContentGeneratorConfig | undefined;
let effectiveAuthType: AuthType | undefined;
let effectiveModelId = selected;
let isRuntime = false;
if (!config) {
onClose();
return;
}
try {
// Determine if this is a runtime model selection
// Runtime model format: $runtime|${authType}|${modelId}
isRuntime = selected.startsWith('$runtime|');
let selectedAuthType: AuthType;
let modelId: string;
if (isRuntime) {
// For runtime models, extract authType from the snapshot ID
// Format: $runtime|${authType}|${modelId}
const parts = selected.split('|');
if (parts.length >= 2 && parts[0] === '$runtime') {
selectedAuthType = parts[1] as AuthType;
} else {
selectedAuthType = authType as AuthType;
}
modelId = selected; // Pass the full snapshot ID to switchModel
} else {
const sep = '::';
const idx = selected.indexOf(sep);
selectedAuthType = (
idx >= 0 ? selected.slice(0, idx) : authType
) as AuthType;
modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected;
}
await config.switchModel(
selectedAuthType,
modelId,
selectedAuthType !== authType &&
selectedAuthType === AuthType.QWEN_OAUTH
? { requireCachedCredentials: true }
: undefined,
);
if (!isRuntime) {
const event = new ModelSlashCommandEvent(modelId);
logModelSlashCommand(config, event);
}
after = config.getContentGeneratorConfig?.() as
| ContentGeneratorConfig
| undefined;
effectiveAuthType = after?.authType ?? selectedAuthType ?? authType;
effectiveModelId = after?.model ?? modelId;
} catch (e) {
const baseErrorMessage = e instanceof Error ? e.message : String(e);
const errorPrefix = isRuntime
? 'Failed to switch to runtime model.'
: `Failed to switch model to '${effectiveModelId ?? selected}'.`;
setErrorMessage(`${errorPrefix}\n\n${baseErrorMessage}`);
return;
}
handleModelSwitchSuccess({
settings,
uiState,
after,
effectiveAuthType,
effectiveModelId,
isRuntime,
});
onClose();
},
[authType, config, onClose, settings, uiState, setErrorMessage],
);
const hasModels = MODEL_OPTIONS.length > 0;
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>{t('Select Model')}</Text>
{!hasModels ? (
<Box marginTop={1} flexDirection="column">
<Text color={theme.status.warning}>
{t(
'No models available for the current authentication type ({{authType}}).',
{
authType: authType ? String(authType) : t('(none)'),
},
)}
</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t(
'Please configure models in settings.modelProviders or use environment variables.',
)}
</Text>
</Box>
</Box>
) : (
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={MODEL_OPTIONS}
onSelect={handleSelect}
onHighlight={handleHighlight}
initialIndex={initialIndex}
showNumbers={true}
/>
</Box>
)}
{highlightedEntry && (
<Box marginTop={1} flexDirection="column">
<Box
borderStyle="single"
borderTop
borderBottom={false}
borderLeft={false}
borderRight={false}
borderColor={theme.border.default}
/>
<DetailRow
label={t('Modality')}
value={formatModalities(highlightedEntry.model.modalities)}
/>
<DetailRow
label={t('Context Window')}
value={formatContextWindow(
highlightedEntry.model.contextWindowSize,
)}
/>
{highlightedEntry.authType !== AuthType.QWEN_OAUTH && (
<>
<DetailRow
label="Base URL"
value={highlightedEntry.model.baseUrl ?? t('(default)')}
/>
<DetailRow
label="API Key"
value={highlightedEntry.model.envKey ?? t('(not set)')}
/>
</>
)}
</Box>
)}
{errorMessage && (
<Box marginTop={1} flexDirection="column" paddingX={1}>
<Text color={theme.status.error} wrap="wrap">
{errorMessage}
</Text>
</Box>
)}
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
{t('Enter to select, ↑↓ to navigate, Esc to close')}
</Text>
</Box>
</Box>
);
}