mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 23:42:03 +00:00
Merge branch 'main' into feat/tpm-throttling-retry-wenshao
This commit is contained in:
commit
3153ff5caa
55 changed files with 3003 additions and 654 deletions
|
|
@ -94,6 +94,7 @@ import {
|
|||
useSettingInputRequests,
|
||||
usePluginChoiceRequests,
|
||||
} from './hooks/useExtensionUpdates.js';
|
||||
import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js';
|
||||
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
|
||||
|
|
@ -232,6 +233,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
config.getWorkingDir(),
|
||||
);
|
||||
|
||||
const { codingPlanUpdateRequest, dismissCodingPlanUpdate } =
|
||||
useCodingPlanUpdates(settings, config, historyManager.addItem);
|
||||
|
||||
const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
|
||||
const openPermissionsDialog = useCallback(
|
||||
() => setPermissionsDialogOpen(true),
|
||||
|
|
@ -402,6 +406,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
pendingAuthType,
|
||||
qwenAuthState,
|
||||
handleAuthSelect,
|
||||
handleCodingPlanSubmit,
|
||||
openAuthDialog,
|
||||
cancelAuthentication,
|
||||
} = useAuthCommand(settings, config, historyManager.addItem, refreshStatic);
|
||||
|
|
@ -1275,6 +1280,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
!!shellConfirmationRequest ||
|
||||
!!confirmationRequest ||
|
||||
confirmUpdateExtensionRequests.length > 0 ||
|
||||
!!codingPlanUpdateRequest ||
|
||||
settingInputRequests.length > 0 ||
|
||||
pluginChoiceRequests.length > 0 ||
|
||||
!!loopDetectionConfirmationRequest ||
|
||||
|
|
@ -1339,6 +1345,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
codingPlanUpdateRequest,
|
||||
settingInputRequests,
|
||||
pluginChoiceRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
|
|
@ -1429,6 +1436,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
codingPlanUpdateRequest,
|
||||
settingInputRequests,
|
||||
pluginChoiceRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
|
|
@ -1508,10 +1516,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
setAuthState,
|
||||
onAuthError,
|
||||
cancelAuthentication,
|
||||
handleCodingPlanSubmit,
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
dismissCodingPlanUpdate,
|
||||
closePermissionsDialog,
|
||||
setShellModeActive,
|
||||
vimHandleInput,
|
||||
|
|
@ -1552,10 +1562,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
setAuthState,
|
||||
onAuthError,
|
||||
cancelAuthentication,
|
||||
handleCodingPlanSubmit,
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
dismissCodingPlanUpdate,
|
||||
closePermissionsDialog,
|
||||
setShellModeActive,
|
||||
vimHandleInput,
|
||||
|
|
|
|||
|
|
@ -169,9 +169,9 @@ describe('AuthDialog', () => {
|
|||
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// Since the auth dialog only shows OpenAI option now,
|
||||
// Since the auth dialog shows API-KEY option now,
|
||||
// it won't show GEMINI_API_KEY messages
|
||||
expect(lastFrame()).toContain('OpenAI');
|
||||
expect(lastFrame()).toContain('API-KEY');
|
||||
});
|
||||
|
||||
it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => {
|
||||
|
|
@ -257,15 +257,17 @@ describe('AuthDialog', () => {
|
|||
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// Since the auth dialog only shows OpenAI option now,
|
||||
// Since the auth dialog shows API-KEY option now,
|
||||
// it won't show GEMINI_API_KEY messages
|
||||
expect(lastFrame()).toContain('OpenAI');
|
||||
expect(lastFrame()).toContain('API-KEY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QWEN_DEFAULT_AUTH_TYPE environment variable', () => {
|
||||
it('should select the auth type specified by QWEN_DEFAULT_AUTH_TYPE', () => {
|
||||
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI;
|
||||
// QWEN_OAUTH is the only valid AuthType that can be selected via env var
|
||||
// API-KEY is not an AuthType enum value, so it cannot be selected this way
|
||||
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.QWEN_OAUTH;
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
|
|
@ -302,8 +304,8 @@ describe('AuthDialog', () => {
|
|||
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// This is a bit brittle, but it's the best way to check which item is selected.
|
||||
expect(lastFrame()).toContain('● 2. OpenAI');
|
||||
// QWEN_OAUTH is the first option, so it should be selected
|
||||
expect(lastFrame()).toContain('● 1. Qwen OAuth');
|
||||
});
|
||||
|
||||
it('should fall back to default if QWEN_DEFAULT_AUTH_TYPE is not set', () => {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,20 @@ import type React from 'react';
|
|||
import { useState } from 'react';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { Box, Text } from 'ink';
|
||||
import Link from 'ink-link';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||
import { ApiKeyInput } from '../components/ApiKeyInput.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
const MODEL_PROVIDERS_DOCUMENTATION_URL =
|
||||
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders';
|
||||
|
||||
function parseDefaultAuthType(
|
||||
defaultAuthType: string | undefined,
|
||||
): AuthType | null {
|
||||
|
|
@ -28,30 +34,57 @@ function parseDefaultAuthType(
|
|||
return null;
|
||||
}
|
||||
|
||||
// Sub-mode types for API-KEY authentication
|
||||
type ApiKeySubMode = 'coding-plan' | 'custom';
|
||||
|
||||
// View level for navigation
|
||||
type ViewLevel = 'main' | 'api-key-sub' | 'api-key-input' | 'custom-info';
|
||||
|
||||
export function AuthDialog(): React.JSX.Element {
|
||||
const { pendingAuthType, authError } = useUIState();
|
||||
const { handleAuthSelect: onAuthSelect } = useUIActions();
|
||||
const {
|
||||
handleAuthSelect: onAuthSelect,
|
||||
handleCodingPlanSubmit,
|
||||
onAuthError,
|
||||
} = useUIActions();
|
||||
const config = useConfig();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [viewLevel, setViewLevel] = useState<ViewLevel>('main');
|
||||
const [apiKeySubModeIndex, setApiKeySubModeIndex] = useState<number>(0);
|
||||
|
||||
const items = [
|
||||
// Main authentication entries
|
||||
const mainItems = [
|
||||
{
|
||||
key: AuthType.QWEN_OAUTH,
|
||||
label: t('Qwen OAuth'),
|
||||
value: AuthType.QWEN_OAUTH,
|
||||
},
|
||||
{
|
||||
key: AuthType.USE_OPENAI,
|
||||
label: t('OpenAI'),
|
||||
value: AuthType.USE_OPENAI,
|
||||
key: 'API-KEY',
|
||||
label: t('API-KEY'),
|
||||
value: 'API-KEY' as const,
|
||||
},
|
||||
];
|
||||
|
||||
// API-KEY sub-mode entries
|
||||
const apiKeySubItems = [
|
||||
{
|
||||
key: 'coding-plan',
|
||||
label: t('Coding Plan (Bailian)'),
|
||||
value: 'coding-plan' as ApiKeySubMode,
|
||||
},
|
||||
{
|
||||
key: 'custom',
|
||||
label: t('Custom'),
|
||||
value: 'custom' as ApiKeySubMode,
|
||||
},
|
||||
];
|
||||
|
||||
const initialAuthIndex = Math.max(
|
||||
0,
|
||||
items.findIndex((item) => {
|
||||
mainItems.findIndex((item) => {
|
||||
// Priority 1: pendingAuthType
|
||||
if (pendingAuthType) {
|
||||
return item.value === pendingAuthType;
|
||||
|
|
@ -79,29 +112,78 @@ export function AuthDialog(): React.JSX.Element {
|
|||
const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey);
|
||||
const currentSelectedAuthType =
|
||||
selectedIndex !== null
|
||||
? items[selectedIndex]?.value
|
||||
: items[initialAuthIndex]?.value;
|
||||
? mainItems[selectedIndex]?.value
|
||||
: mainItems[initialAuthIndex]?.value;
|
||||
|
||||
const handleAuthSelect = async (authMethod: AuthType) => {
|
||||
const handleMainSelect = async (
|
||||
value: (typeof mainItems)[number]['value'],
|
||||
) => {
|
||||
setErrorMessage(null);
|
||||
await onAuthSelect(authMethod);
|
||||
onAuthError(null);
|
||||
|
||||
if (value === 'API-KEY') {
|
||||
// Navigate to API-KEY sub-mode selection
|
||||
setViewLevel('api-key-sub');
|
||||
return;
|
||||
}
|
||||
|
||||
// For Qwen OAuth, proceed directly
|
||||
await onAuthSelect(value);
|
||||
};
|
||||
|
||||
const handleHighlight = (authMethod: AuthType) => {
|
||||
const index = items.findIndex((item) => item.value === authMethod);
|
||||
setSelectedIndex(index);
|
||||
const handleApiKeySubSelect = async (subMode: ApiKeySubMode) => {
|
||||
setErrorMessage(null);
|
||||
onAuthError(null);
|
||||
|
||||
if (subMode === 'coding-plan') {
|
||||
setViewLevel('api-key-input');
|
||||
} else {
|
||||
setViewLevel('custom-info');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyInputSubmit = async (apiKey: string) => {
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!apiKey.trim()) {
|
||||
setErrorMessage(t('API key cannot be empty.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit to parent for processing
|
||||
await handleCodingPlanSubmit(apiKey);
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
setErrorMessage(null);
|
||||
onAuthError(null);
|
||||
|
||||
if (viewLevel === 'api-key-sub') {
|
||||
setViewLevel('main');
|
||||
} else if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') {
|
||||
setViewLevel('api-key-sub');
|
||||
}
|
||||
};
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
// Prevent exit if there is an error message.
|
||||
// This means they user is not authenticated yet.
|
||||
// Handle Escape based on current view level
|
||||
if (viewLevel === 'api-key-sub') {
|
||||
handleGoBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') {
|
||||
handleGoBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// For main view, use existing logic
|
||||
if (errorMessage) {
|
||||
return;
|
||||
}
|
||||
if (config.getAuthType() === undefined) {
|
||||
// Prevent exiting if no auth method is set
|
||||
setErrorMessage(
|
||||
t(
|
||||
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
|
||||
|
|
@ -115,51 +197,212 @@ export function AuthDialog(): React.JSX.Element {
|
|||
{ isActive: true },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>{t('Get started')}</Text>
|
||||
// Render main auth selection
|
||||
const renderMainView = () => (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('How would you like to authenticate for this project?')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
items={mainItems}
|
||||
initialIndex={initialAuthIndex}
|
||||
onSelect={handleAuthSelect}
|
||||
onHighlight={handleHighlight}
|
||||
onSelect={handleMainSelect}
|
||||
onHighlight={(value) => {
|
||||
const index = mainItems.findIndex((item) => item.value === value);
|
||||
setSelectedIndex(index);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text color={Colors.Gray}>
|
||||
{currentSelectedAuthType === AuthType.QWEN_OAUTH
|
||||
? t('Login with QwenChat account to use daily free quota.')
|
||||
: t('Use coding plan credentials or your own api-keys/providers.')}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
// Render API-KEY sub-mode selection
|
||||
const renderApiKeySubView = () => (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Select API-KEY configuration mode:')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={apiKeySubItems}
|
||||
initialIndex={apiKeySubModeIndex}
|
||||
onSelect={handleApiKeySubSelect}
|
||||
onHighlight={(value) => {
|
||||
const index = apiKeySubItems.findIndex(
|
||||
(item) => item.value === value,
|
||||
);
|
||||
setApiKeySubModeIndex(index);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text color={Colors.Gray}>
|
||||
{apiKeySubItems[apiKeySubModeIndex]?.value === 'coding-plan'
|
||||
? t("Paste your api key of Bailian Coding Plan and you're all set!")
|
||||
: t(
|
||||
'More instructions about configuring `modelProviders` manually.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme?.text?.secondary}>
|
||||
{t('(Press Escape to go back)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
// Render API key input for coding-plan mode
|
||||
const renderApiKeyInputView = () => (
|
||||
<Box marginTop={1}>
|
||||
<ApiKeyInput onSubmit={handleApiKeyInputSubmit} onCancel={handleGoBack} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
// Render custom mode info
|
||||
const renderCustomInfoView = () => (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text bold>{t('Custom API-KEY Configuration')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
{t('For advanced users who want to configure models manually.')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Please configure your models in settings.json:')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text color={Colors.AccentYellow}>
|
||||
1. {t('Set API key via environment variable (e.g., OPENAI_API_KEY)')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={0} paddingLeft={2}>
|
||||
<Text color={Colors.AccentYellow}>
|
||||
2.{' '}
|
||||
{t(
|
||||
"Add model configuration to modelProviders['openai'] (or other auth types)",
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={0} paddingLeft={2}>
|
||||
<Text color={Colors.AccentYellow}>
|
||||
3.{' '}
|
||||
{t(
|
||||
'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={0} paddingLeft={2}>
|
||||
<Text color={Colors.AccentYellow}>
|
||||
4.{' '}
|
||||
{t(
|
||||
'Use /model command to select your preferred model from the configured list',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme?.text?.secondary}>
|
||||
{t(
|
||||
'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme?.text?.secondary} underline>
|
||||
{t('More instructions please check:')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={0}>
|
||||
<Link url={MODEL_PROVIDERS_DOCUMENTATION_URL} fallback={false}>
|
||||
<Text color={Colors.AccentGreen} underline>
|
||||
{MODEL_PROVIDERS_DOCUMENTATION_URL}
|
||||
</Text>
|
||||
</Link>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme?.text?.secondary}>
|
||||
{t('(Press Escape to go back)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
const getViewTitle = () => {
|
||||
switch (viewLevel) {
|
||||
case 'main':
|
||||
return t('Get started');
|
||||
case 'api-key-sub':
|
||||
return t('API-KEY Configuration');
|
||||
case 'api-key-input':
|
||||
return t('Coding Plan Setup');
|
||||
case 'custom-info':
|
||||
return t('Custom Configuration');
|
||||
default:
|
||||
return t('Get started');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme?.border?.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>{getViewTitle()}</Text>
|
||||
|
||||
{viewLevel === 'main' && renderMainView()}
|
||||
{viewLevel === 'api-key-sub' && renderApiKeySubView()}
|
||||
{viewLevel === 'api-key-input' && renderApiKeyInputView()}
|
||||
{viewLevel === 'custom-info' && renderCustomInfoView()}
|
||||
|
||||
{(authError || errorMessage) && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentRed}>{authError || errorMessage}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentPurple}>{t('(Use Enter to Set Auth)')}</Text>
|
||||
</Box>
|
||||
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
{t(
|
||||
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{viewLevel === 'main' && (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentPurple}>
|
||||
{t('(Use Enter to Set Auth)')}
|
||||
</Text>
|
||||
</Box>
|
||||
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme?.text?.secondary}>
|
||||
{t(
|
||||
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
{t('Terms of Services and Privacy Notice for Qwen Code')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentBlue}>
|
||||
{
|
||||
'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/'
|
||||
}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Terms of Services and Privacy Notice for Qwen Code')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentBlue}>
|
||||
{'https://github.com/QwenLM/Qwen3-Coder/blob/main/README.md'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
Config,
|
||||
ContentGeneratorConfig,
|
||||
ModelProvidersConfig,
|
||||
ProviderModelConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthEvent,
|
||||
|
|
@ -18,11 +19,21 @@ import {
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
||||
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
|
||||
export interface OpenAICredentials {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
}
|
||||
import { useQwenAuth } from '../hooks/useQwenAuth.js';
|
||||
import { AuthState, MessageType } from '../types.js';
|
||||
import type { HistoryItem } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
CODING_PLAN_MODELS,
|
||||
CODING_PLAN_ENV_KEY,
|
||||
CODING_PLAN_VERSION,
|
||||
} from '../../constants/codingPlan.js';
|
||||
|
||||
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
|
||||
|
||||
|
|
@ -272,6 +283,129 @@ export const useAuthCommand = (
|
|||
setAuthError(null);
|
||||
}, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]);
|
||||
|
||||
/**
|
||||
* Handle coding plan submission - generates configs from template and stores api-key
|
||||
*/
|
||||
const handleCodingPlanSubmit = useCallback(
|
||||
async (apiKey: string) => {
|
||||
try {
|
||||
setIsAuthenticating(true);
|
||||
setAuthError(null);
|
||||
|
||||
const envKeyName = CODING_PLAN_ENV_KEY;
|
||||
|
||||
// Get persist scope
|
||||
const persistScope = getPersistScopeForModelSelection(settings);
|
||||
|
||||
// Store api-key in settings.env
|
||||
settings.setValue(persistScope, `env.${envKeyName}`, apiKey);
|
||||
|
||||
// Sync to process.env immediately so refreshAuth can read the apiKey
|
||||
process.env[envKeyName] = apiKey;
|
||||
|
||||
// Generate model configs from template
|
||||
const newConfigs: ProviderModelConfig[] = CODING_PLAN_MODELS.map(
|
||||
(templateConfig) => ({
|
||||
...templateConfig,
|
||||
envKey: envKeyName,
|
||||
}),
|
||||
);
|
||||
|
||||
// Get existing configs
|
||||
const existingConfigs =
|
||||
(
|
||||
settings.merged.modelProviders as ModelProvidersConfig | undefined
|
||||
)?.[AuthType.USE_OPENAI] || [];
|
||||
|
||||
// Identify Coding Plan configs by baseUrl + envKey
|
||||
// Remove existing Coding Plan configs to ensure template changes are applied
|
||||
const isCodingPlanConfig = (config: ProviderModelConfig) =>
|
||||
config.envKey === envKeyName &&
|
||||
CODING_PLAN_MODELS.some(
|
||||
(template) => template.baseUrl === config.baseUrl,
|
||||
);
|
||||
|
||||
// Filter out existing Coding Plan configs, keep user custom configs
|
||||
const nonCodingPlanConfigs = existingConfigs.filter(
|
||||
(existing) => !isCodingPlanConfig(existing),
|
||||
);
|
||||
|
||||
// Add new Coding Plan configs at the beginning
|
||||
const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs];
|
||||
|
||||
// Persist to modelProviders
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
`modelProviders.${AuthType.USE_OPENAI}`,
|
||||
updatedConfigs,
|
||||
);
|
||||
|
||||
// Also persist authType
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
'security.auth.selectedType',
|
||||
AuthType.USE_OPENAI,
|
||||
);
|
||||
|
||||
// Persist coding plan version for future update detection
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
'codingPlan.version',
|
||||
CODING_PLAN_VERSION,
|
||||
);
|
||||
|
||||
// If there are configs, use the first one as the model
|
||||
if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) {
|
||||
settings.setValue(persistScope, 'model.name', updatedConfigs[0].id);
|
||||
}
|
||||
|
||||
// Hot-reload model providers configuration before refreshAuth
|
||||
// This ensures ModelsConfig has the latest configuration from settings.json
|
||||
const updatedModelProviders: ModelProvidersConfig = {
|
||||
...(settings.merged.modelProviders as
|
||||
| ModelProvidersConfig
|
||||
| undefined),
|
||||
[AuthType.USE_OPENAI]: updatedConfigs,
|
||||
};
|
||||
config.reloadModelProvidersConfig(updatedModelProviders);
|
||||
|
||||
// Refresh auth with the new configuration
|
||||
await config.refreshAuth(AuthType.USE_OPENAI);
|
||||
|
||||
// Success handling
|
||||
setAuthError(null);
|
||||
setAuthState(AuthState.Authenticated);
|
||||
setIsAuthDialogOpen(false);
|
||||
setIsAuthenticating(false);
|
||||
|
||||
// Trigger UI refresh
|
||||
onAuthChange?.();
|
||||
|
||||
// Add success message
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t(
|
||||
'Authenticated successfully with Coding Plan. API key is stored in settings.env.',
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Log success
|
||||
const authEvent = new AuthEvent(
|
||||
AuthType.USE_OPENAI,
|
||||
'coding-plan',
|
||||
'success',
|
||||
);
|
||||
logAuth(config, authEvent);
|
||||
} catch (error) {
|
||||
handleAuthFailure(error);
|
||||
}
|
||||
},
|
||||
[settings, config, handleAuthFailure, addItem, onAuthChange],
|
||||
);
|
||||
|
||||
/**
|
||||
/**
|
||||
* We previously used a useEffect to trigger authentication automatically when
|
||||
|
|
@ -322,6 +456,7 @@ export const useAuthCommand = (
|
|||
pendingAuthType,
|
||||
qwenAuthState,
|
||||
handleAuthSelect,
|
||||
handleCodingPlanSubmit,
|
||||
openAuthDialog,
|
||||
cancelAuthentication,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ describe('authCommand', () => {
|
|||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(authCommand.name).toBe('auth');
|
||||
expect(authCommand.description).toBe('change the auth method');
|
||||
expect(authCommand.description).toBe(
|
||||
'Configure authentication information for login',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ import { t } from '../../i18n/index.js';
|
|||
|
||||
export const authCommand: SlashCommand = {
|
||||
name: 'auth',
|
||||
altNames: ['login'],
|
||||
get description() {
|
||||
return t('change the auth method');
|
||||
return t('Configure authentication information for login');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
|
|
|
|||
75
packages/cli/src/ui/components/ApiKeyInput.tsx
Normal file
75
packages/cli/src/ui/components/ApiKeyInput.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { TextInput } from './shared/TextInput.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import Link from 'ink-link';
|
||||
|
||||
interface ApiKeyInputProps {
|
||||
onSubmit: (apiKey: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const CODING_PLAN_API_KEY_URL =
|
||||
'https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan';
|
||||
|
||||
export function ApiKeyInput({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: ApiKeyInputProps): React.JSX.Element {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onCancel();
|
||||
} else if (key.name === 'return') {
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (!trimmedKey) {
|
||||
setError(t('API key cannot be empty.'));
|
||||
return;
|
||||
}
|
||||
onSubmit(trimmedKey);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text>{t('Please enter your API key:')}</Text>
|
||||
</Box>
|
||||
<TextInput value={apiKey} onChange={setApiKey} placeholder="sk-sp-..." />
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentRed}>{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('You can get your exclusive Coding Plan API-KEY here:')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={0}>
|
||||
<Link url={CODING_PLAN_API_KEY_URL} fallback={false}>
|
||||
<Text color={Colors.AccentGreen} underline>
|
||||
{CODING_PLAN_API_KEY_URL}
|
||||
</Text>
|
||||
</Link>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
{t('(Press Enter to submit, Escape to cancel)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,7 +17,6 @@ import { ThemeDialog } from './ThemeDialog.js';
|
|||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
||||
import { AuthDialog } from '../auth/AuthDialog.js';
|
||||
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
|
||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
|
|
@ -56,16 +55,6 @@ export const DialogManager = ({
|
|||
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
|
||||
uiState;
|
||||
|
||||
const getDefaultOpenAIConfig = () => {
|
||||
const fromSettings = settings.merged.security?.auth;
|
||||
const modelSettings = settings.merged.model;
|
||||
return {
|
||||
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
|
||||
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
|
||||
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
|
||||
};
|
||||
};
|
||||
|
||||
if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) {
|
||||
return (
|
||||
<WelcomeBackDialog
|
||||
|
|
@ -133,6 +122,15 @@ export const DialogManager = ({
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.codingPlanUpdateRequest) {
|
||||
return (
|
||||
<ConsentPrompt
|
||||
prompt={uiState.codingPlanUpdateRequest.prompt}
|
||||
onConfirm={uiState.codingPlanUpdateRequest.onConfirm}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.settingInputRequests.length > 0) {
|
||||
const request = uiState.settingInputRequests[0];
|
||||
// Use settingName as key to force re-mount when switching between different settings
|
||||
|
|
@ -251,28 +249,8 @@ export const DialogManager = ({
|
|||
}
|
||||
|
||||
if (uiState.isAuthenticating) {
|
||||
if (uiState.pendingAuthType === AuthType.USE_OPENAI) {
|
||||
const defaults = getDefaultOpenAIConfig();
|
||||
return (
|
||||
<OpenAIKeyPrompt
|
||||
onSubmit={(apiKey, baseUrl, model) => {
|
||||
uiActions.handleAuthSelect(AuthType.USE_OPENAI, {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
uiActions.cancelAuthentication();
|
||||
uiActions.setAuthState(AuthState.Updating);
|
||||
}}
|
||||
defaultApiKey={defaults.apiKey}
|
||||
defaultBaseUrl={defaults.baseUrl}
|
||||
defaultModel={defaults.model}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// OpenAI authentication now handled through AuthDialog with coding-plan/custom sub-modes
|
||||
// Qwen OAuth remains as a separate flow
|
||||
if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) {
|
||||
return (
|
||||
<QwenOAuthProgress
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
|
||||
|
||||
// Mock useKeypress hook
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('OpenAIKeyPrompt', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
it('should render the prompt correctly', () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('OpenAI Configuration Required');
|
||||
expect(lastFrame()).toContain(
|
||||
'https://bailian.console.aliyun.com/?tab=model#/api-key',
|
||||
);
|
||||
expect(lastFrame()).toContain(
|
||||
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show the component with proper styling', () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('OpenAI Configuration Required');
|
||||
expect(output).toContain('API Key:');
|
||||
expect(output).toContain('Base URL:');
|
||||
expect(output).toContain('Model:');
|
||||
expect(output).toContain(
|
||||
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle paste with control characters', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { stdin } = render(
|
||||
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
// Simulate paste with control characters
|
||||
const pasteWithControlChars = '\x1b[200~sk-test123\x1b[201~';
|
||||
stdin.write(pasteWithControlChars);
|
||||
|
||||
// Wait a bit for processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// The component should have filtered out the control characters
|
||||
// and only kept 'sk-test123'
|
||||
expect(onSubmit).not.toHaveBeenCalled(); // Should not submit yet
|
||||
});
|
||||
});
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface OpenAIKeyPromptProps {
|
||||
onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
|
||||
onCancel: () => void;
|
||||
defaultApiKey?: string;
|
||||
defaultBaseUrl?: string;
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
export const credentialSchema = z.object({
|
||||
apiKey: z.string().min(1, 'API key is required'),
|
||||
baseUrl: z
|
||||
.union([z.string().url('Base URL must be a valid URL'), z.literal('')])
|
||||
.optional(),
|
||||
model: z.string().min(1, 'Model must be a non-empty string').optional(),
|
||||
});
|
||||
|
||||
export type OpenAICredentials = z.infer<typeof credentialSchema>;
|
||||
|
||||
export function OpenAIKeyPrompt({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
defaultApiKey,
|
||||
defaultBaseUrl,
|
||||
defaultModel,
|
||||
}: OpenAIKeyPromptProps): React.JSX.Element {
|
||||
const [apiKey, setApiKey] = useState(defaultApiKey || '');
|
||||
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl || '');
|
||||
const [model, setModel] = useState(defaultModel || '');
|
||||
const [currentField, setCurrentField] = useState<
|
||||
'apiKey' | 'baseUrl' | 'model'
|
||||
>('apiKey');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const validateAndSubmit = () => {
|
||||
setValidationError(null);
|
||||
|
||||
try {
|
||||
const validated = credentialSchema.parse({
|
||||
apiKey: apiKey.trim(),
|
||||
baseUrl: baseUrl.trim() || undefined,
|
||||
model: model.trim() || undefined,
|
||||
});
|
||||
|
||||
onSubmit(
|
||||
validated.apiKey,
|
||||
validated.baseUrl === '' ? '' : validated.baseUrl || '',
|
||||
validated.model || '',
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const errorMessage = error.errors
|
||||
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||
.join(', ');
|
||||
setValidationError(
|
||||
t('Invalid credentials: {{errorMessage}}', { errorMessage }),
|
||||
);
|
||||
} else {
|
||||
setValidationError(t('Failed to validate credentials'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
// Handle escape
|
||||
if (key.name === 'escape') {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Enter key
|
||||
if (key.name === 'return') {
|
||||
if (currentField === 'apiKey') {
|
||||
// 允许空 API key 跳转到下一个字段,让用户稍后可以返回修改
|
||||
setCurrentField('baseUrl');
|
||||
return;
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setCurrentField('model');
|
||||
return;
|
||||
} else if (currentField === 'model') {
|
||||
// 只有在提交时才检查 API key 是否为空
|
||||
if (apiKey.trim()) {
|
||||
validateAndSubmit();
|
||||
} else {
|
||||
// 如果 API key 为空,回到 API key 字段
|
||||
setCurrentField('apiKey');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Tab key for field navigation
|
||||
if (key.name === 'tab') {
|
||||
if (currentField === 'apiKey') {
|
||||
setCurrentField('baseUrl');
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setCurrentField('model');
|
||||
} else if (currentField === 'model') {
|
||||
setCurrentField('apiKey');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle arrow keys for field navigation
|
||||
if (key.name === 'up') {
|
||||
if (currentField === 'baseUrl') {
|
||||
setCurrentField('apiKey');
|
||||
} else if (currentField === 'model') {
|
||||
setCurrentField('baseUrl');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'down') {
|
||||
if (currentField === 'apiKey') {
|
||||
setCurrentField('baseUrl');
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setCurrentField('model');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle backspace/delete
|
||||
if (key.name === 'backspace' || key.name === 'delete') {
|
||||
if (currentField === 'apiKey') {
|
||||
setApiKey((prev) => prev.slice(0, -1));
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setBaseUrl((prev) => prev.slice(0, -1));
|
||||
} else if (currentField === 'model') {
|
||||
setModel((prev) => prev.slice(0, -1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle paste mode - if it's a paste event with content
|
||||
if (key.paste && key.sequence) {
|
||||
// 过滤粘贴相关的控制序列
|
||||
let cleanInput = key.sequence
|
||||
// 过滤 ESC 开头的控制序列(如 \u001b[200~、\u001b[201~ 等)
|
||||
.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // eslint-disable-line no-control-regex
|
||||
// 过滤粘贴开始标记 [200~
|
||||
.replace(/\[200~/g, '')
|
||||
// 过滤粘贴结束标记 [201~
|
||||
.replace(/\[201~/g, '')
|
||||
// 过滤单独的 [ 和 ~ 字符(可能是粘贴标记的残留)
|
||||
.replace(/^\[|~$/g, '');
|
||||
|
||||
// 再过滤所有不可见字符(ASCII < 32,除了回车换行)
|
||||
cleanInput = cleanInput
|
||||
.split('')
|
||||
.filter((ch) => ch.charCodeAt(0) >= 32)
|
||||
.join('');
|
||||
|
||||
if (cleanInput.length > 0) {
|
||||
if (currentField === 'apiKey') {
|
||||
setApiKey((prev) => prev + cleanInput);
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setBaseUrl((prev) => prev + cleanInput);
|
||||
} else if (currentField === 'model') {
|
||||
setModel((prev) => prev + cleanInput);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle regular character input
|
||||
if (key.sequence && !key.ctrl && !key.meta) {
|
||||
// Filter control characters
|
||||
const cleanInput = key.sequence
|
||||
.split('')
|
||||
.filter((ch) => ch.charCodeAt(0) >= 32)
|
||||
.join('');
|
||||
|
||||
if (cleanInput.length > 0) {
|
||||
if (currentField === 'apiKey') {
|
||||
setApiKey((prev) => prev + cleanInput);
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setBaseUrl((prev) => prev + cleanInput);
|
||||
} else if (currentField === 'model') {
|
||||
setModel((prev) => prev + cleanInput);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
{t('OpenAI Configuration Required')}
|
||||
</Text>
|
||||
{validationError && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentRed}>{validationError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
{t(
|
||||
'Please enter your OpenAI configuration. You can get an API key from',
|
||||
)}{' '}
|
||||
<Text color={Colors.AccentBlue}>
|
||||
https://bailian.console.aliyun.com/?tab=model#/api-key
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="row">
|
||||
<Box width={12}>
|
||||
<Text
|
||||
color={currentField === 'apiKey' ? Colors.AccentBlue : Colors.Gray}
|
||||
>
|
||||
{t('API Key:')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text>
|
||||
{currentField === 'apiKey' ? '> ' : ' '}
|
||||
{apiKey || ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="row">
|
||||
<Box width={12}>
|
||||
<Text
|
||||
color={currentField === 'baseUrl' ? Colors.AccentBlue : Colors.Gray}
|
||||
>
|
||||
{t('Base URL:')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text>
|
||||
{currentField === 'baseUrl' ? '> ' : ' '}
|
||||
{baseUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="row">
|
||||
<Box width={12}>
|
||||
<Text
|
||||
color={currentField === 'model' ? Colors.AccentBlue : Colors.Gray}
|
||||
>
|
||||
{t('Model:')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text>
|
||||
{currentField === 'model' ? '> ' : ' '}
|
||||
{model}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
{t('Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import type {
|
|||
PlanResultDisplay,
|
||||
AnsiOutput,
|
||||
Config,
|
||||
McpToolProgressData,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { AgentExecutionDisplay } from '../subagents/index.js';
|
||||
import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js';
|
||||
|
|
@ -113,6 +114,22 @@ const useResultDisplayRenderer = (
|
|||
};
|
||||
}
|
||||
|
||||
// Check for McpToolProgressData
|
||||
if (
|
||||
typeof resultDisplay === 'object' &&
|
||||
resultDisplay !== null &&
|
||||
'type' in resultDisplay &&
|
||||
resultDisplay.type === 'mcp_tool_progress'
|
||||
) {
|
||||
const progress = resultDisplay as McpToolProgressData;
|
||||
const msg = progress.message ?? `Progress: ${progress.progress}`;
|
||||
const totalStr = progress.total != null ? `/${progress.total}` : '';
|
||||
return {
|
||||
type: 'string',
|
||||
data: `⏳ [${progress.progress}${totalStr}] ${msg}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for AnsiOutput
|
||||
if (
|
||||
typeof resultDisplay === 'object' &&
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@ import {
|
|||
import { type SettingScope } from '../../config/settings.js';
|
||||
import type { AuthState } from '../types.js';
|
||||
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
|
||||
import { type OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
|
||||
export interface OpenAICredentials {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface UIActions {
|
||||
openThemeDialog: () => void;
|
||||
|
|
@ -35,8 +40,9 @@ export interface UIActions {
|
|||
authType: AuthType | undefined,
|
||||
credentials?: OpenAICredentials,
|
||||
) => Promise<void>;
|
||||
handleCodingPlanSubmit: (apiKey: string) => Promise<void>;
|
||||
setAuthState: (state: AuthState) => void;
|
||||
onAuthError: (error: string) => void;
|
||||
onAuthError: (error: string | null) => void;
|
||||
cancelAuthentication: () => void;
|
||||
handleEditorSelect: (
|
||||
editorType: EditorType | undefined,
|
||||
|
|
@ -45,6 +51,7 @@ export interface UIActions {
|
|||
exitEditorDialog: () => void;
|
||||
closeSettingsDialog: () => void;
|
||||
closeModelDialog: () => void;
|
||||
dismissCodingPlanUpdate: () => void;
|
||||
closePermissionsDialog: () => void;
|
||||
setShellModeActive: (value: boolean) => void;
|
||||
vimHandleInput: (key: Key) => boolean;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import type { UpdateObject } from '../utils/updateCheck.js';
|
|||
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||
import { type CodingPlanUpdateRequest } from '../hooks/useCodingPlanUpdates.js';
|
||||
|
||||
export interface UIState {
|
||||
history: HistoryItem[];
|
||||
|
|
@ -60,6 +61,7 @@ export interface UIState {
|
|||
shellConfirmationRequest: ShellConfirmationRequest | null;
|
||||
confirmationRequest: ConfirmationRequest | null;
|
||||
confirmUpdateExtensionRequests: ConfirmationRequest[];
|
||||
codingPlanUpdateRequest: CodingPlanUpdateRequest | undefined;
|
||||
settingInputRequests: SettingInputRequest[];
|
||||
pluginChoiceRequests: PluginChoiceRequest[];
|
||||
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
|
||||
|
|
|
|||
288
packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts
Normal file
288
packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useCodingPlanUpdates } from './useCodingPlanUpdates.js';
|
||||
import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Mock the constants module
|
||||
vi.mock('../../constants/codingPlan.js', async () => {
|
||||
const actual = await vi.importActual('../../constants/codingPlan.js');
|
||||
return {
|
||||
...actual,
|
||||
CODING_PLAN_VERSION: 'test-version-hash',
|
||||
CODING_PLAN_MODELS: [
|
||||
{
|
||||
id: 'test-model-1',
|
||||
name: 'Test Model 1',
|
||||
baseUrl: 'https://test.example.com/v1',
|
||||
description: 'Test model 1',
|
||||
envKey: 'BAILIAN_CODING_PLAN_API_KEY',
|
||||
},
|
||||
{
|
||||
id: 'test-model-2',
|
||||
name: 'Test Model 2',
|
||||
baseUrl: 'https://test.example.com/v1',
|
||||
description: 'Test model 2',
|
||||
envKey: 'BAILIAN_CODING_PLAN_API_KEY',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
describe('useCodingPlanUpdates', () => {
|
||||
const mockSettings = {
|
||||
merged: {
|
||||
modelProviders: {},
|
||||
codingPlan: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
isTrusted: true,
|
||||
workspace: { settings: {} },
|
||||
user: { settings: {} },
|
||||
};
|
||||
|
||||
const mockConfig = {
|
||||
reloadModelProvidersConfig: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAddItem = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env[CODING_PLAN_ENV_KEY];
|
||||
});
|
||||
|
||||
describe('version comparison', () => {
|
||||
it('should not show update prompt when no version is stored', () => {
|
||||
mockSettings.merged.codingPlan = {};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not show update prompt when versions match', () => {
|
||||
mockSettings.merged.codingPlan = { version: 'test-version-hash' };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should show update prompt when versions differ', async () => {
|
||||
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.codingPlanUpdateRequest).toBeDefined();
|
||||
});
|
||||
|
||||
expect(result.current.codingPlanUpdateRequest?.prompt).toContain(
|
||||
'New model configurations',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update execution', () => {
|
||||
it('should execute update when user confirms', async () => {
|
||||
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
|
||||
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
|
||||
mockSettings.merged.modelProviders = {
|
||||
[AuthType.USE_OPENAI]: [
|
||||
{
|
||||
id: 'test-model-1',
|
||||
baseUrl: 'https://test.example.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
},
|
||||
{
|
||||
id: 'custom-model',
|
||||
baseUrl: 'https://custom.example.com',
|
||||
envKey: 'CUSTOM_API_KEY',
|
||||
},
|
||||
],
|
||||
};
|
||||
mockConfig.refreshAuth.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.codingPlanUpdateRequest).toBeDefined();
|
||||
});
|
||||
|
||||
// Confirm the update
|
||||
await result.current.codingPlanUpdateRequest!.onConfirm(true);
|
||||
|
||||
// Wait for async update to complete
|
||||
await waitFor(() => {
|
||||
// Should update model providers (at least 2 calls: modelProviders + version)
|
||||
expect(mockSettings.setValue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should update version
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'codingPlan.version',
|
||||
'test-version-hash',
|
||||
);
|
||||
|
||||
// Should reload and refresh auth
|
||||
expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled();
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI);
|
||||
|
||||
// Should show success message
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: expect.stringContaining('updated successfully'),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not execute update when user declines', async () => {
|
||||
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.codingPlanUpdateRequest).toBeDefined();
|
||||
});
|
||||
|
||||
// Decline the update
|
||||
await result.current.codingPlanUpdateRequest!.onConfirm(false);
|
||||
|
||||
// Should not update anything
|
||||
expect(mockSettings.setValue).not.toHaveBeenCalled();
|
||||
expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should preserve non-Coding Plan configs during update', async () => {
|
||||
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
|
||||
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
|
||||
const customConfig = {
|
||||
id: 'custom-model',
|
||||
baseUrl: 'https://custom.example.com',
|
||||
envKey: 'CUSTOM_API_KEY',
|
||||
};
|
||||
mockSettings.merged.modelProviders = {
|
||||
[AuthType.USE_OPENAI]: [
|
||||
{
|
||||
id: 'test-model-1',
|
||||
baseUrl: 'https://test.example.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
},
|
||||
customConfig,
|
||||
],
|
||||
};
|
||||
mockConfig.refreshAuth.mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.codingPlanUpdateRequest).toBeDefined();
|
||||
});
|
||||
|
||||
await result.current.codingPlanUpdateRequest!.onConfirm(true);
|
||||
|
||||
// Wait for async update to complete
|
||||
await waitFor(() => {
|
||||
// Should preserve custom config - verify setValue was called
|
||||
expect(mockSettings.setValue).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing API key error', async () => {
|
||||
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.codingPlanUpdateRequest).toBeDefined();
|
||||
});
|
||||
|
||||
await result.current.codingPlanUpdateRequest!.onConfirm(true);
|
||||
|
||||
// Should show error message
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissUpdate', () => {
|
||||
it('should clear update request when dismissed', async () => {
|
||||
mockSettings.merged.codingPlan = { version: 'old-version-hash' };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCodingPlanUpdates(
|
||||
mockSettings as never,
|
||||
mockConfig as never,
|
||||
mockAddItem,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.codingPlanUpdateRequest).toBeDefined();
|
||||
});
|
||||
|
||||
result.current.dismissCodingPlanUpdate();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.codingPlanUpdateRequest).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
201
packages/cli/src/ui/hooks/useCodingPlanUpdates.ts
Normal file
201
packages/cli/src/ui/hooks/useCodingPlanUpdates.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
||||
import {
|
||||
CODING_PLAN_MODELS,
|
||||
CODING_PLAN_ENV_KEY,
|
||||
CODING_PLAN_VERSION,
|
||||
} from '../../constants/codingPlan.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export interface CodingPlanUpdateRequest {
|
||||
prompt: string;
|
||||
onConfirm: (confirmed: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a config is a Coding Plan configuration by matching baseUrl and envKey.
|
||||
* This ensures only configs from the Coding Plan provider are identified.
|
||||
*/
|
||||
function isCodingPlanConfig(config: {
|
||||
baseUrl?: string;
|
||||
envKey?: string;
|
||||
}): boolean {
|
||||
return (
|
||||
config.envKey === CODING_PLAN_ENV_KEY &&
|
||||
CODING_PLAN_MODELS.some((template) => template.baseUrl === config.baseUrl)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for detecting and handling Coding Plan template updates.
|
||||
* Compares the persisted version with the current template version
|
||||
* and prompts the user to update if they differ.
|
||||
*/
|
||||
export function useCodingPlanUpdates(
|
||||
settings: LoadedSettings,
|
||||
config: Config,
|
||||
addItem: (
|
||||
item: { type: 'info' | 'error' | 'warning'; text: string },
|
||||
timestamp: number,
|
||||
) => void,
|
||||
) {
|
||||
const [updateRequest, setUpdateRequest] = useState<
|
||||
CodingPlanUpdateRequest | undefined
|
||||
>();
|
||||
|
||||
/**
|
||||
* Execute the Coding Plan configuration update.
|
||||
* Removes old Coding Plan configs and replaces them with new ones from the template.
|
||||
*/
|
||||
const executeUpdate = useCallback(async () => {
|
||||
try {
|
||||
const persistScope = getPersistScopeForModelSelection(settings);
|
||||
|
||||
// Get current configs
|
||||
const currentConfigs =
|
||||
(
|
||||
settings.merged.modelProviders as
|
||||
| Record<string, Array<Record<string, unknown>>>
|
||||
| undefined
|
||||
)?.[AuthType.USE_OPENAI] || [];
|
||||
|
||||
// Filter out Coding Plan configs (keep user custom configs)
|
||||
const nonCodingPlanConfigs = currentConfigs.filter(
|
||||
(cfg) =>
|
||||
!isCodingPlanConfig({
|
||||
baseUrl: cfg['baseUrl'] as string | undefined,
|
||||
envKey: cfg['envKey'] as string | undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
// Generate new configs from template with the stored API key
|
||||
const apiKey = process.env[CODING_PLAN_ENV_KEY];
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
t(
|
||||
'Coding Plan API key not found. Please re-authenticate with Coding Plan.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const newConfigs = CODING_PLAN_MODELS.map((templateConfig) => ({
|
||||
...templateConfig,
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
}));
|
||||
|
||||
// Combine: new Coding Plan configs at the front, user configs preserved
|
||||
const updatedConfigs = [
|
||||
...newConfigs,
|
||||
...(nonCodingPlanConfigs as Array<Record<string, unknown>>),
|
||||
] as Array<Record<string, unknown>>;
|
||||
|
||||
// Persist updated model providers
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
`modelProviders.${AuthType.USE_OPENAI}`,
|
||||
updatedConfigs,
|
||||
);
|
||||
|
||||
// Update the version
|
||||
settings.setValue(
|
||||
persistScope,
|
||||
'codingPlan.version',
|
||||
CODING_PLAN_VERSION,
|
||||
);
|
||||
|
||||
// Hot-reload model providers configuration
|
||||
const updatedModelProviders = {
|
||||
...(settings.merged.modelProviders as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
[AuthType.USE_OPENAI]: updatedConfigs,
|
||||
};
|
||||
config.reloadModelProvidersConfig(
|
||||
updatedModelProviders as unknown as ModelProvidersConfig,
|
||||
);
|
||||
|
||||
// Refresh auth with the new configuration
|
||||
await config.refreshAuth(AuthType.USE_OPENAI);
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: t(
|
||||
'Coding Plan configuration updated successfully. New models are now available.',
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
addItem(
|
||||
{
|
||||
type: 'error',
|
||||
text: t('Failed to update Coding Plan configuration: {{message}}', {
|
||||
message: errorMessage,
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}, [settings, config, addItem]);
|
||||
|
||||
/**
|
||||
* Check for version mismatch and prompt user for update if needed.
|
||||
*/
|
||||
const checkForUpdates = useCallback(() => {
|
||||
const savedVersion = (
|
||||
settings.merged as { codingPlan?: { version?: string } }
|
||||
).codingPlan?.version;
|
||||
|
||||
// If no version is stored, user hasn't used Coding Plan yet - skip check
|
||||
if (!savedVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If versions match, no update needed
|
||||
if (savedVersion === CODING_PLAN_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Version mismatch - prompt user for update
|
||||
setUpdateRequest({
|
||||
prompt: t(
|
||||
'New model configurations are available for Bailian Coding Plan. Update now?',
|
||||
),
|
||||
onConfirm: async (confirmed: boolean) => {
|
||||
setUpdateRequest(undefined);
|
||||
if (confirmed) {
|
||||
await executeUpdate();
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [settings, executeUpdate]);
|
||||
|
||||
// Check for updates on mount
|
||||
useEffect(() => {
|
||||
checkForUpdates();
|
||||
}, [checkForUpdates]);
|
||||
|
||||
const dismissCodingPlanUpdate = useCallback(() => {
|
||||
setUpdateRequest(undefined);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
codingPlanUpdateRequest: updateRequest,
|
||||
dismissCodingPlanUpdate,
|
||||
};
|
||||
}
|
||||
|
|
@ -7,7 +7,12 @@
|
|||
import { useCallback } from 'react';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
|
||||
interface OpenAICredentials {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface DialogCloseOptions {
|
||||
// Theme dialog
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue