mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
feat(vscode): replace OAuth with Coding Plan / API Key provider setup (#3398)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* refactor(core): move codingPlan constants from cli to core package Extract Coding Plan region configs, model templates, and utility functions into packages/core/src/constants/ so both CLI and VSCode extension can import from a shared source of truth. * refactor(cli): import codingPlan constants from core instead of local path Update all CLI files to import CodingPlanRegion, CODING_PLAN_ENV_KEY, and related utilities from @qwen-code/qwen-code-core, replacing the local ../../constants/codingPlan.js imports. * feat(vscode-ide-companion): replace login flow with provider setup via VSCode Settings Replace the OAuth-based login command with a settings-driven provider configuration flow. Users now configure Coding Plan or API Key providers through VSCode Settings (qwen-code.*), which auto-syncs to ~/.qwen/settings.json. - Rename login command to auth, opening VSCode Settings panel - Add /auth2 interactive flow (QuickPick + InputBox) - Add ProviderSetupForm onboarding component with inline config - Add bidirectional sync between VSCode settings and ~/.qwen/settings.json - Add settingsWriter service for direct settings.json read/write - Add VSCode configuration schema (provider, apiKey, region, model, etc.) - Update all login/session messages to use auth terminology * refactor(vscode-ide-companion): rename auth2→auth, remove dead code, fix sync guard - Rename auth2 to auth for all message types, handlers, and slash command - Remove unused InfoBanner.tsx (128 lines, no references) - Remove dead openProviderSettings handler (no callers) - Remove redundant qwen-code.baseUrl VSCode setting (already in modelProviders) - Replace unreliable setTimeout(500) sync guard with await Promise.all + finally - Clean up old authHandler/setAuthHandler in favor of authInteractiveHandler * refactor(vscode-ide-companion): remove dead VSCode Settings plumbing, simplify sync - Remove qwen-code.modelProviders and qwen-code.model from package.json (model switching handled by chat UI's /model command, not VSCode Settings) - Remove connectWithSettings message handler and plumbing (no webview component sends this message type) - Remove handleConnectWithSettings method from WebViewProvider - Simplify syncVSCodeSettingsToQwenConfig: only sync provider/apiKey/region - Simplify syncQwenConfigToVSCodeSettings: only populate provider/apiKey/region - Simplify QwenSettingsForVSCode interface: remove modelProviders and model - Improve Onboarding UI: logo above card, better hierarchy, arrow icon on button * fix(vscode-ide-companion): add missing vscode.workspace mock in test Add onDidChangeConfiguration and getConfiguration to the vscode.workspace mock in WebViewProvider.test.ts to fix CI test failures. * fix(vscode-ide-companion): clean up stale coding plan state, add auth cancel handling, add tests - Clear CODING_PLAN_ENV_KEY and codingPlan metadata when switching to api-key mode - Add authCancelled notification when QuickPick/InputBox is dismissed - ProviderSetupForm resets button state on authCancelled - syncVSCodeSettingsToQwenConfig returns false for api-key mode (no-op) - Fix Onboarding vertical centering (flex-1 min-h-0) - Import from @qwen-code/qwen-code-core top-level instead of deep paths - Add tests: settingsWriter, ProviderSetupForm cancel, AuthMessageHandler cancel, WebViewProvider sync - Fix redundant ternary in pick() helper * fix(vscode-ide-companion): force center Onboarding against parent override Parent container uses [&>*]:items-start and [&>*]:text-left which overrides Tailwind classes. Use inline style for alignItems/justifyContent/textAlign to ensure Onboarding is always centered both horizontally and vertically. * fix(vscode-ide-companion): bundle onboarding logo * test(vscode-ide-companion): add png loader to bundle test * fix(vscode-ide-companion/webview): avoid redundant auth sync reconnects * fix(vscode-ide-companion/webview): fix auth sync typecheck * docs(vscode-ide-companion): clarify auth restoration flow * fix(webui): use bracket access for permission drawer plan content * fix(vscode-ide-companion): guard authSuccess emission on actual auth state After reconnecting in handleAuthInteractive, doInitializeAgentConnection may return without throwing even when credentials are rejected (it sends authState:false internally and returns early). Previously we unconditionally emitted authSuccess, which contradicted the failed auth state and could briefly show a success toast before re-opening the auth flow. Now we check this.authState after reconnection: only emit authSuccess when authentication actually succeeded, otherwise emit authError with a clear credentials message. Addresses review feedback from PR #3398. * fix(vscode): address auth setup review feedback * fix(vscode-ide-companion): guard concurrent auth flows, merge model providers - Add authFlowActive mutex and autoAuthTimer to WebViewProvider so startInteractiveAuth() cancels the deferred auto-auth timeout, preventing two overlapping QuickPick flows from a single command. - Change writeModelProvidersConfig() to merge new entries with existing non-target models (different envKey) instead of replacing the entire array, preserving unrelated providers like Coding Plan. * fix(vscode-ide-companion): handle apiKey clearing as de-auth signal, fix auto-auth race, clean imports - Add clearPersistedAuth() to settingsWriter.ts: removes selectedType, API keys, and coding plan metadata from ~/.qwen/settings.json - Config change handler now detects empty apiKey with active agent and triggers de-auth: clear credentials, disconnect, update authState - Auto-auth timer callback now properly sets authFlowActive mutex to prevent concurrent auth flows with startInteractiveAuth() - Add test covering the de-auth path (clearPersistedAuth + disconnect) - Fix import formatting in 7 CLI files (spacing, trailing commas) - Remove duplicate comment in attemptAuthStateRestoration() * fix(vscode-ide-companion): scope de-auth to apiKey changes only The previous de-auth logic triggered on any auth-related setting change where syncVSCodeSettingsToQwenConfig() returned false. For api-key providers this is the normal path (interactive auth owns config), so changing codingPlanRegion or provider would incorrectly wipe OPENAI_API_KEY. Now the de-auth branch only fires when e.affectsConfiguration('qwen-code.apiKey') is true AND the value is empty, preventing false-positive credential clearing. Add regression test: non-apiKey setting changes on an api-key provider must not trigger clearPersistedAuth or disconnect. * fix(vscode-ide-companion): add disconnect to mock type to fix CI typecheck The hoisted mockQwenAgentManagerInstances type was missing the disconnect property, causing TS2339 in the de-auth test assertions.
This commit is contained in:
parent
ebe364d0b8
commit
e49867a762
37 changed files with 2284 additions and 282 deletions
|
|
@ -17,7 +17,7 @@ import {
|
|||
isCodingPlanConfig,
|
||||
CodingPlanRegion,
|
||||
CODING_PLAN_ENV_KEY,
|
||||
} from '../../constants/codingPlan.js';
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
||||
import { backupSettingsFile } from '../../utils/settingsUtils.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@
|
|||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { showAuthStatus } from './handler.js';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js';
|
||||
import { AuthType, CODING_PLAN_ENV_KEY } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@
|
|||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthType,
|
||||
CodingPlanRegion,
|
||||
isCodingPlanConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { Box, Text } from 'ink';
|
||||
import Link from 'ink-link';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
|
@ -18,10 +22,6 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
|||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
CodingPlanRegion,
|
||||
isCodingPlanConfig,
|
||||
} from '../../constants/codingPlan.js';
|
||||
import {
|
||||
ALIBABA_STANDARD_API_KEY_ENDPOINTS,
|
||||
type AlibabaStandardRegion,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import {
|
|||
AuthType,
|
||||
getErrorMessage,
|
||||
logAuth,
|
||||
getCodingPlanConfig,
|
||||
isCodingPlanConfig,
|
||||
CodingPlanRegion,
|
||||
CODING_PLAN_ENV_KEY,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
|
@ -29,12 +33,6 @@ 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 {
|
||||
getCodingPlanConfig,
|
||||
isCodingPlanConfig,
|
||||
CodingPlanRegion,
|
||||
CODING_PLAN_ENV_KEY,
|
||||
} from '../../constants/codingPlan.js';
|
||||
import { backupSettingsFile } from '../../utils/settingsUtils.js';
|
||||
import {
|
||||
ALIBABA_STANDARD_API_KEY_ENDPOINTS,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { TextInput } from './shared/TextInput.js';
|
|||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { CodingPlanRegion } from '../../constants/codingPlan.js';
|
||||
import { CodingPlanRegion } from '@qwen-code/qwen-code-core';
|
||||
import Link from 'ink-link';
|
||||
|
||||
interface ApiKeyInputProps {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@
|
|||
*/
|
||||
|
||||
import { Box } from 'ink';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { AuthType, isCodingPlanConfig } from '@qwen-code/qwen-code-core';
|
||||
import { Header, AuthDisplayType } from './Header.js';
|
||||
import { Tips } from './Tips.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { isCodingPlanConfig } from '../../constants/codingPlan.js';
|
||||
|
||||
interface AppHeaderProps {
|
||||
version: string;
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import {
|
|||
type AuthType,
|
||||
type EditorType,
|
||||
type ApprovalMode,
|
||||
type CodingPlanRegion,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { type SettingScope } from '../../config/settings.js';
|
||||
import { type CodingPlanRegion } from '../../constants/codingPlan.js';
|
||||
import { type AlibabaStandardRegion } from '../../constants/alibabaStandardApiKey.js';
|
||||
import type { AuthState } from '../types.js';
|
||||
import { type ArenaDialogType } from '../hooks/useArenaCommand.js';
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import {
|
|||
CODING_PLAN_ENV_KEY,
|
||||
getCodingPlanConfig,
|
||||
CodingPlanRegion,
|
||||
} from '../../constants/codingPlan.js';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
AuthType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Get region configs for testing
|
||||
const chinaConfig = getCodingPlanConfig(CodingPlanRegion.CHINA);
|
||||
|
|
|
|||
|
|
@ -6,15 +6,15 @@
|
|||
|
||||
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 {
|
||||
AuthType,
|
||||
isCodingPlanConfig,
|
||||
getCodingPlanConfig,
|
||||
CodingPlanRegion,
|
||||
CODING_PLAN_ENV_KEY,
|
||||
} from '../../constants/codingPlan.js';
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export interface CodingPlanUpdateRequest {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import type { ExtendedSystemInfo } from './systemInfo.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { isCodingPlanConfig } from '../constants/codingPlan.js';
|
||||
import { isCodingPlanConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Field configuration for system information display
|
||||
|
|
|
|||
309
packages/core/src/constants/codingPlan.ts
Normal file
309
packages/core/src/constants/codingPlan.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Coding Plan constants — shared between CLI and VSCode extension.
|
||||
* Single source of truth for model templates, regions, and env keys.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { ModelConfig } from '../models/types.js';
|
||||
|
||||
/**
|
||||
* Coding plan regions
|
||||
*/
|
||||
export enum CodingPlanRegion {
|
||||
CHINA = 'china',
|
||||
GLOBAL = 'global',
|
||||
}
|
||||
|
||||
/**
|
||||
* Coding plan template - array of model configurations
|
||||
* When user provides an api-key, these configs will be cloned with envKey pointing to the stored api-key
|
||||
*/
|
||||
export type CodingPlanTemplate = ModelConfig[];
|
||||
|
||||
/**
|
||||
* Environment variable key for storing the coding plan API key.
|
||||
* Unified key for both regions since they are mutually exclusive.
|
||||
*/
|
||||
export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY';
|
||||
|
||||
/**
|
||||
* Computes the version hash for the coding plan template.
|
||||
* Uses SHA256 of the JSON-serialized template for deterministic versioning.
|
||||
* @param template - The template to compute version for
|
||||
* @returns Hexadecimal string representing the template version
|
||||
*/
|
||||
export function computeCodingPlanVersion(template: CodingPlanTemplate): string {
|
||||
const templateString = JSON.stringify(template);
|
||||
return createHash('sha256').update(templateString).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the complete coding plan template for a specific region.
|
||||
* China region uses legacy description to maintain backward compatibility.
|
||||
* Global region uses new description with region indicator.
|
||||
* @param region - The region to generate template for
|
||||
* @returns Complete model configuration array for the region
|
||||
*/
|
||||
export function generateCodingPlanTemplate(
|
||||
region: CodingPlanRegion,
|
||||
): CodingPlanTemplate {
|
||||
if (region === CodingPlanRegion.CHINA) {
|
||||
return [
|
||||
{
|
||||
id: 'qwen3.5-plus',
|
||||
name: '[ModelStudio Coding Plan] qwen3.5-plus',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: { enable_thinking: true },
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3.6-plus',
|
||||
name: '[ModelStudio Coding Plan] qwen3.6-plus',
|
||||
description: 'Currently available to Pro subscribers only.',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: { enable_thinking: true },
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-5',
|
||||
name: '[ModelStudio Coding Plan] glm-5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: { enable_thinking: true },
|
||||
contextWindowSize: 202752,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kimi-k2.5',
|
||||
name: '[ModelStudio Coding Plan] kimi-k2.5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: { enable_thinking: true },
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2.5',
|
||||
name: '[ModelStudio Coding Plan] MiniMax-M2.5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: { enable_thinking: true },
|
||||
contextWindowSize: 196608,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-plus',
|
||||
name: '[ModelStudio Coding Plan] qwen3-coder-plus',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-next',
|
||||
name: '[ModelStudio Coding Plan] qwen3-coder-next',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-max-2026-01-23',
|
||||
name: '[ModelStudio Coding Plan] qwen3-max-2026-01-23',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: { enable_thinking: true },
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-4.7',
|
||||
name: '[ModelStudio Coding Plan] glm-4.7',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: { enable_thinking: true },
|
||||
contextWindowSize: 202752,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Global region
|
||||
return [
|
||||
{
|
||||
id: 'qwen3.5-plus',
|
||||
name: '[ModelStudio Coding Plan for Global/Intl] qwen3.5-plus',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: { enable_thinking: true },
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3.6-plus',
|
||||
name: '[ModelStudio Coding Plan for Global/Intl] qwen3.6-plus',
|
||||
description: 'Currently available to Pro subscribers only.',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: { enable_thinking: true },
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-plus',
|
||||
name: '[ModelStudio Coding Plan for Global/Intl] qwen3-coder-plus',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-next',
|
||||
name: '[ModelStudio Coding Plan for Global/Intl] qwen3-coder-next',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-max-2026-01-23',
|
||||
name: '[ModelStudio Coding Plan for Global/Intl] qwen3-max-2026-01-23',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: { enable_thinking: true },
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-4.7',
|
||||
name: '[ModelStudio Coding Plan for Global/Intl] glm-4.7',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: { enable_thinking: true },
|
||||
contextWindowSize: 202752,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-5',
|
||||
name: '[ModelStudio Coding Plan for Global/Intl] glm-5',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: { enable_thinking: true },
|
||||
contextWindowSize: 202752,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2.5',
|
||||
name: '[ModelStudio Coding Plan for Global/Intl] MiniMax-M2.5',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: { enable_thinking: true },
|
||||
contextWindowSize: 196608,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kimi-k2.5',
|
||||
name: '[ModelStudio Coding Plan for Global/Intl] kimi-k2.5',
|
||||
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: { enable_thinking: true },
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the complete configuration for a specific region.
|
||||
* @param region - The region to use
|
||||
* @returns Object containing template, baseUrl, and version
|
||||
*/
|
||||
export function getCodingPlanConfig(region: CodingPlanRegion) {
|
||||
const template = generateCodingPlanTemplate(region);
|
||||
const baseUrl =
|
||||
region === CodingPlanRegion.CHINA
|
||||
? 'https://coding.dashscope.aliyuncs.com/v1'
|
||||
: 'https://coding-intl.dashscope.aliyuncs.com/v1';
|
||||
return {
|
||||
template,
|
||||
baseUrl,
|
||||
version: computeCodingPlanVersion(template),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique base URLs for coding plan (used for filtering/config detection).
|
||||
* @returns Array of base URLs
|
||||
*/
|
||||
export function getCodingPlanBaseUrls(): string[] {
|
||||
return [
|
||||
'https://coding.dashscope.aliyuncs.com/v1',
|
||||
'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a config belongs to Coding Plan (any region).
|
||||
* Returns the region if matched, or false if not a Coding Plan config.
|
||||
* @param baseUrl - The baseUrl to check
|
||||
* @param envKey - The envKey to check
|
||||
* @returns The region if matched, false otherwise
|
||||
*/
|
||||
export function isCodingPlanConfig(
|
||||
baseUrl: string | undefined,
|
||||
envKey: string | undefined,
|
||||
): CodingPlanRegion | false {
|
||||
if (!baseUrl || !envKey) return false;
|
||||
if (envKey !== CODING_PLAN_ENV_KEY) return false;
|
||||
if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') {
|
||||
return CodingPlanRegion.CHINA;
|
||||
}
|
||||
if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') {
|
||||
return CodingPlanRegion.GLOBAL;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get region from baseUrl.
|
||||
* @param baseUrl - The baseUrl to check
|
||||
* @returns The region if matched, null otherwise
|
||||
*/
|
||||
export function getRegionFromBaseUrl(
|
||||
baseUrl: string | undefined,
|
||||
): CodingPlanRegion | null {
|
||||
if (!baseUrl) return null;
|
||||
if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') {
|
||||
return CodingPlanRegion.CHINA;
|
||||
}
|
||||
if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') {
|
||||
return CodingPlanRegion.GLOBAL;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -44,6 +44,19 @@ export {
|
|||
validateModelConfig,
|
||||
} from './models/index.js';
|
||||
|
||||
// Coding Plan constants
|
||||
export {
|
||||
CodingPlanRegion,
|
||||
type CodingPlanTemplate,
|
||||
CODING_PLAN_ENV_KEY,
|
||||
computeCodingPlanVersion,
|
||||
generateCodingPlanTemplate,
|
||||
getCodingPlanConfig,
|
||||
getCodingPlanBaseUrls,
|
||||
isCodingPlanConfig,
|
||||
getRegionFromBaseUrl,
|
||||
} from './constants/codingPlan.js';
|
||||
|
||||
// Output formatting
|
||||
export * from './output/json-formatter.js';
|
||||
export * from './output/types.js';
|
||||
|
|
|
|||
|
|
@ -218,6 +218,9 @@ async function main() {
|
|||
logLevel: 'silent',
|
||||
plugins: [reactDedupPlugin, cssInjectPlugin, esbuildProblemMatcherPlugin],
|
||||
jsx: 'automatic', // Use new JSX transform (React 17+)
|
||||
loader: {
|
||||
'.png': 'dataurl',
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': production ? '"production"' : '"development"',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -111,8 +111,8 @@
|
|||
"icon": "./assets/icon.png"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.login",
|
||||
"title": "Qwen Code: Login"
|
||||
"command": "qwen-code.auth",
|
||||
"title": "Qwen Code: Auth"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.focusChat",
|
||||
|
|
@ -138,8 +138,7 @@
|
|||
"when": "qwen.diff.isVisible"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.login",
|
||||
"when": "false"
|
||||
"command": "qwen-code.auth"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
|
|
@ -159,6 +158,45 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"title": "Qwen Code",
|
||||
"properties": {
|
||||
"qwen-code.provider": {
|
||||
"order": 0,
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"coding-plan",
|
||||
"api-key"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Alibaba Cloud Coding Plan — configurable from VS Code Settings",
|
||||
"Configured via Qwen Code: Auth or the onboarding button"
|
||||
],
|
||||
"default": "coding-plan",
|
||||
"markdownDescription": "**Coding Plan**: enter API Key + Region here to sync `~/.qwen/settings.json`.\n\n**API Key**: use **Qwen Code: Auth** or the onboarding button to configure ModelStudio or custom OpenAI-compatible providers."
|
||||
},
|
||||
"qwen-code.apiKey": {
|
||||
"order": 1,
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "API key used for **Coding Plan** settings sync. For **API Key** providers, configure the full provider details through **Qwen Code: Auth**."
|
||||
},
|
||||
"qwen-code.codingPlanRegion": {
|
||||
"order": 2,
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"china",
|
||||
"global"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"China — 阿里云百炼 (aliyun.com)",
|
||||
"Global — Alibaba Cloud (alibabacloud.com)"
|
||||
],
|
||||
"default": "china",
|
||||
"markdownDescription": "Region for Coding Plan. _(Only used when Provider is `coding-plan`)_"
|
||||
}
|
||||
}
|
||||
},
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "qwen.diff.accept",
|
||||
|
|
|
|||
4
packages/vscode-ide-companion/src/assets.d.ts
vendored
Normal file
4
packages/vscode-ide-companion/src/assets.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module '*.png' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
authCommand,
|
||||
focusChatCommand,
|
||||
openNewChatTabCommand,
|
||||
registerNewCommands,
|
||||
|
|
@ -70,6 +71,7 @@ describe('registerNewCommands', () => {
|
|||
const provider = {
|
||||
show: vi.fn().mockResolvedValue(undefined),
|
||||
createNewSession: vi.fn().mockResolvedValue(undefined),
|
||||
startInteractiveAuth: vi.fn().mockResolvedValue(undefined),
|
||||
setInitialModelId: vi.fn(),
|
||||
};
|
||||
|
||||
|
|
@ -90,6 +92,27 @@ describe('registerNewCommands', () => {
|
|||
expect(provider.setInitialModelId).toHaveBeenCalledWith('glm-5');
|
||||
});
|
||||
|
||||
it('auth opens the interactive provider setup flow instead of VS Code settings', async () => {
|
||||
const provider = {
|
||||
show: vi.fn().mockResolvedValue(undefined),
|
||||
startInteractiveAuth: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
registerNewCommands(
|
||||
context as never,
|
||||
log,
|
||||
diffManager as never,
|
||||
() => [provider as never],
|
||||
vi.fn(() => provider as never),
|
||||
);
|
||||
|
||||
await getRegisteredHandler(authCommand)();
|
||||
|
||||
expect(provider.show).toHaveBeenCalledTimes(1);
|
||||
expect(provider.startInteractiveAuth).toHaveBeenCalledTimes(1);
|
||||
expect(executeCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('focusChat focuses the secondary sidebar when it is supported', async () => {
|
||||
registerNewCommands(
|
||||
context as never,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export const runQwenCodeCommand = 'qwen-code.runQwenCode';
|
|||
export const showDiffCommand = 'qwenCode.showDiff';
|
||||
export const openChatCommand = 'qwen-code.openChat';
|
||||
export const openNewChatTabCommand = 'qwenCode.openNewChatTab';
|
||||
export const loginCommand = 'qwen-code.login';
|
||||
export const authCommand = 'qwen-code.auth';
|
||||
export const focusChatCommand = 'qwen-code.focusChat';
|
||||
export const newConversationCommand = 'qwen-code.newConversation';
|
||||
export const showLogsCommand = 'qwen-code.showLogs';
|
||||
|
|
@ -101,15 +101,15 @@ export function registerNewCommands(
|
|||
);
|
||||
|
||||
disposables.push(
|
||||
vscode.commands.registerCommand(loginCommand, async () => {
|
||||
vscode.commands.registerCommand(authCommand, async () => {
|
||||
const providers = getWebViewProviders();
|
||||
if (providers.length > 0) {
|
||||
await providers[providers.length - 1].forceReLogin();
|
||||
} else {
|
||||
vscode.window.showInformationMessage(
|
||||
'Please open Qwen Code chat first before logging in.',
|
||||
);
|
||||
}
|
||||
const provider =
|
||||
providers.length > 0
|
||||
? providers[providers.length - 1]
|
||||
: createWebViewProvider();
|
||||
|
||||
await provider.show();
|
||||
await provider.startInteractiveAuth();
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockGetGlobalSettingsPath } = vi.hoisted(() => ({
|
||||
mockGetGlobalSettingsPath: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
Storage: {
|
||||
...actual.Storage,
|
||||
getGlobalSettingsPath: mockGetGlobalSettingsPath,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { CODING_PLAN_ENV_KEY, AuthType } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
readQwenSettingsForVSCode,
|
||||
writeCodingPlanConfig,
|
||||
writeModelProvidersConfig,
|
||||
} from './settingsWriter.js';
|
||||
|
||||
describe('settingsWriter', () => {
|
||||
let tempDir: string;
|
||||
let settingsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-vscode-settings-'));
|
||||
settingsPath = path.join(tempDir, '.qwen', 'settings.json');
|
||||
mockGetGlobalSettingsPath.mockReturnValue(settingsPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('clears stale coding plan metadata when writing api-key providers', () => {
|
||||
writeCodingPlanConfig('china', 'coding-plan-key');
|
||||
|
||||
writeModelProvidersConfig({
|
||||
apiKey: 'manual-key',
|
||||
modelProviders: {
|
||||
'gpt-4o': 'https://api.openai.com/v1',
|
||||
},
|
||||
activeModel: 'gpt-4o',
|
||||
});
|
||||
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(settingsPath, 'utf-8'),
|
||||
) as Record<string, unknown>;
|
||||
const env = settings.env as Record<string, string>;
|
||||
const modelProviders = settings.modelProviders as Record<string, unknown>;
|
||||
const openaiModels = modelProviders[AuthType.USE_OPENAI] as Array<
|
||||
Record<string, string>
|
||||
>;
|
||||
|
||||
expect(env.OPENAI_API_KEY).toBe('manual-key');
|
||||
expect(env[CODING_PLAN_ENV_KEY]).toBeUndefined();
|
||||
expect(settings.codingPlan).toBeUndefined();
|
||||
expect(settings.model).toEqual({ name: 'gpt-4o' });
|
||||
// The new entry must be present
|
||||
expect(openaiModels[0]).toEqual({
|
||||
id: 'gpt-4o',
|
||||
name: 'gpt-4o',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
envKey: 'OPENAI_API_KEY',
|
||||
});
|
||||
// Non-target entries (Coding Plan) are preserved, not silently deleted
|
||||
const preserved = openaiModels.filter(
|
||||
(m) => m.envKey === CODING_PLAN_ENV_KEY,
|
||||
);
|
||||
expect(preserved.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('reads an api-key configuration after switching away from coding plan', () => {
|
||||
writeCodingPlanConfig('china', 'coding-plan-key');
|
||||
|
||||
writeModelProvidersConfig({
|
||||
apiKey: 'manual-key',
|
||||
modelProviders: {
|
||||
'gpt-4o': 'https://api.openai.com/v1',
|
||||
},
|
||||
activeModel: 'gpt-4o',
|
||||
});
|
||||
|
||||
expect(readQwenSettingsForVSCode()).toEqual({
|
||||
provider: 'api-key',
|
||||
apiKey: 'manual-key',
|
||||
codingPlanRegion: 'china',
|
||||
});
|
||||
});
|
||||
});
|
||||
294
packages/vscode-ide-companion/src/services/settingsWriter.ts
Normal file
294
packages/vscode-ide-companion/src/services/settingsWriter.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Settings writer for VSCode extension.
|
||||
* Handles bidirectional sync between VSCode Settings and ~/.qwen/settings.json.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
AuthType,
|
||||
Storage,
|
||||
CodingPlanRegion,
|
||||
CODING_PLAN_ENV_KEY,
|
||||
getCodingPlanConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Model providers as key-value map: modelId → baseUrl.
|
||||
* This is the format VSCode Settings UI can render as an editable table.
|
||||
*/
|
||||
export type VSCodeModelProviders = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Values extracted from ~/.qwen/settings.json for populating VSCode Settings.
|
||||
*/
|
||||
export interface QwenSettingsForVSCode {
|
||||
provider: 'coding-plan' | 'api-key';
|
||||
apiKey: string;
|
||||
codingPlanRegion: 'china' | 'global';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low-level read/write helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read ~/.qwen/settings.json. Returns {} if missing or invalid.
|
||||
*/
|
||||
function readSettings(): Record<string, unknown> {
|
||||
try {
|
||||
const content = fs.readFileSync(Storage.getGlobalSettingsPath(), 'utf-8');
|
||||
return JSON.parse(content) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write ~/.qwen/settings.json (creates dir if needed).
|
||||
*/
|
||||
function writeSettings(settings: Record<string, unknown>): void {
|
||||
const settingsPath = Storage.getGlobalSettingsPath();
|
||||
const dir = path.dirname(settingsPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure nested objects exist at the given key path.
|
||||
*/
|
||||
function ensureNestedObject(
|
||||
obj: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): Record<string, unknown> {
|
||||
let current = obj;
|
||||
for (const key of keys) {
|
||||
if (!current[key] || typeof current[key] !== 'object') {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find OpenAI-compatible model entries from modelProviders.
|
||||
* CLI uses AuthType.USE_OPENAI ('openai') as the key, but some legacy
|
||||
* configs may use other keys. Check both.
|
||||
*/
|
||||
function findOpenaiModels(
|
||||
modelProviders: Record<string, unknown> | undefined,
|
||||
): Array<Record<string, unknown>> {
|
||||
if (!modelProviders) {
|
||||
return [];
|
||||
}
|
||||
for (const key of [AuthType.USE_OPENAI, 'use_openai']) {
|
||||
const arr = modelProviders[key];
|
||||
if (Array.isArray(arr) && arr.length > 0) {
|
||||
return arr as Array<Record<string, unknown>>;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write: VSCode Settings → ~/.qwen/settings.json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Write Coding Plan configuration to ~/.qwen/settings.json.
|
||||
* Auto-injects model providers from the regional template,
|
||||
* preserving any existing non-Coding-Plan entries.
|
||||
*
|
||||
* @returns The injected models as a VSCode key-value map (modelId → baseUrl)
|
||||
*/
|
||||
export function writeCodingPlanConfig(
|
||||
region: 'china' | 'global',
|
||||
apiKey: string,
|
||||
): VSCodeModelProviders {
|
||||
const settings = readSettings();
|
||||
const codingRegion =
|
||||
region === 'global' ? CodingPlanRegion.GLOBAL : CodingPlanRegion.CHINA;
|
||||
const planConfig = getCodingPlanConfig(codingRegion);
|
||||
|
||||
// Auth
|
||||
const auth = ensureNestedObject(settings, 'security', 'auth');
|
||||
auth.selectedType = AuthType.USE_OPENAI;
|
||||
|
||||
// API key
|
||||
const env = ensureNestedObject(settings, 'env');
|
||||
env[CODING_PLAN_ENV_KEY] = apiKey;
|
||||
|
||||
// Model providers — merge Coding Plan templates with existing non-CP entries
|
||||
const providers = ensureNestedObject(settings, 'modelProviders');
|
||||
const existing = findOpenaiModels(
|
||||
settings.modelProviders as Record<string, unknown>,
|
||||
);
|
||||
const nonCodingPlan = existing.filter(
|
||||
(e) => e.envKey !== CODING_PLAN_ENV_KEY,
|
||||
);
|
||||
providers[AuthType.USE_OPENAI] = [...planConfig.template, ...nonCodingPlan];
|
||||
|
||||
// Coding Plan metadata
|
||||
settings.codingPlan = { region: codingRegion, version: planConfig.version };
|
||||
|
||||
// Default model
|
||||
const defaultModelId = planConfig.template[0]?.id ?? 'qwen3.5-plus';
|
||||
settings.model = { name: defaultModelId };
|
||||
|
||||
writeSettings(settings);
|
||||
|
||||
// Return key-value map for VSCode settings
|
||||
const result: VSCodeModelProviders = {};
|
||||
for (const m of planConfig.template) {
|
||||
result[m.id] = m.baseUrl || '';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write model providers from VSCode Settings (key-value map) to ~/.qwen/settings.json.
|
||||
* Used when provider = "api-key" and user edits the modelProviders map.
|
||||
*
|
||||
* @param params.apiKey - The API key
|
||||
* @param params.modelProviders - Map of modelId → baseUrl
|
||||
* @param params.activeModel - Currently selected model ID
|
||||
*/
|
||||
export function writeModelProvidersConfig(params: {
|
||||
apiKey: string;
|
||||
modelProviders: VSCodeModelProviders;
|
||||
activeModel: string;
|
||||
}): void {
|
||||
const settings = readSettings();
|
||||
|
||||
// Auth
|
||||
const auth = ensureNestedObject(settings, 'security', 'auth');
|
||||
auth.selectedType = AuthType.USE_OPENAI;
|
||||
|
||||
// API key
|
||||
const env = ensureNestedObject(settings, 'env');
|
||||
env['OPENAI_API_KEY'] = params.apiKey;
|
||||
delete env[CODING_PLAN_ENV_KEY];
|
||||
|
||||
// Convert key-value map to CLI's array format and merge with existing
|
||||
// non-target entries so reconfiguring one provider doesn't silently
|
||||
// delete others (e.g. Coding Plan entries with a different envKey).
|
||||
const providers = ensureNestedObject(settings, 'modelProviders');
|
||||
const modelArray = Object.entries(params.modelProviders).map(
|
||||
([id, baseUrl]) => ({
|
||||
id,
|
||||
name: id,
|
||||
baseUrl: baseUrl || 'https://api.openai.com/v1',
|
||||
envKey: 'OPENAI_API_KEY',
|
||||
}),
|
||||
);
|
||||
const existing = findOpenaiModels(
|
||||
settings.modelProviders as Record<string, unknown>,
|
||||
);
|
||||
const nonTarget = existing.filter((e) => e.envKey !== 'OPENAI_API_KEY');
|
||||
providers[AuthType.USE_OPENAI] = [...modelArray, ...nonTarget];
|
||||
|
||||
// Active model
|
||||
if (params.activeModel) {
|
||||
settings.model = { name: params.activeModel };
|
||||
}
|
||||
|
||||
delete settings.codingPlan;
|
||||
|
||||
writeSettings(settings);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read: ~/.qwen/settings.json → VSCode Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read ~/.qwen/settings.json and extract values for VSCode Settings UI.
|
||||
* Returns null if no valid configuration found.
|
||||
*/
|
||||
export function readQwenSettingsForVSCode(): QwenSettingsForVSCode | null {
|
||||
const settings = readSettings();
|
||||
|
||||
const security = settings.security as Record<string, unknown> | undefined;
|
||||
const auth = security?.auth as Record<string, unknown> | undefined;
|
||||
if (!auth?.selectedType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
const codingPlan = settings.codingPlan as Record<string, unknown> | undefined;
|
||||
|
||||
// Determine if this is a Coding Plan setup
|
||||
const hasCodingPlanKey = !!env[CODING_PLAN_ENV_KEY];
|
||||
const hasCodingPlanRegion = !!codingPlan?.region;
|
||||
|
||||
if (hasCodingPlanKey && hasCodingPlanRegion) {
|
||||
return {
|
||||
provider: 'coding-plan',
|
||||
apiKey: env[CODING_PLAN_ENV_KEY] || '',
|
||||
codingPlanRegion: (codingPlan?.region as 'china' | 'global') || 'china',
|
||||
};
|
||||
}
|
||||
|
||||
// Non-Coding-Plan — find API key from model providers
|
||||
const modelProviders = settings.modelProviders as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const openaiModels = findOpenaiModels(modelProviders);
|
||||
const firstEnvKey = (openaiModels[0]?.envKey as string) || 'OPENAI_API_KEY';
|
||||
const apiKey = env[firstEnvKey] || '';
|
||||
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'api-key',
|
||||
apiKey,
|
||||
codingPlanRegion: 'china',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear persisted auth credentials from ~/.qwen/settings.json.
|
||||
* Removes API keys, auth type selection, and coding plan metadata
|
||||
* so runtime state matches the cleared VS Code settings.
|
||||
*/
|
||||
export function clearPersistedAuth(): void {
|
||||
try {
|
||||
const settings = readSettings();
|
||||
|
||||
// Remove auth type selection
|
||||
const security = settings.security as Record<string, unknown> | undefined;
|
||||
if (security?.auth) {
|
||||
delete (security.auth as Record<string, unknown>).selectedType;
|
||||
}
|
||||
|
||||
// Remove API keys
|
||||
const env = settings.env as Record<string, unknown> | undefined;
|
||||
if (env) {
|
||||
delete env[CODING_PLAN_ENV_KEY];
|
||||
delete env['OPENAI_API_KEY'];
|
||||
}
|
||||
|
||||
// Remove coding plan metadata
|
||||
delete settings.codingPlan;
|
||||
|
||||
writeSettings(settings);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[settingsWriter] Failed to clear persisted auth credentials:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,11 @@ import type { ApprovalModeValue } from './approvalModeValueTypes.js';
|
|||
// Private / Qwen-specific types (not part of ACP spec)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const authMethod = 'qwen-oauth';
|
||||
// Default auth method for ACP authenticate requests.
|
||||
// Value matches AuthType.USE_OPENAI from @qwen-code/qwen-code-core.
|
||||
// Cannot import directly because this file is used in the webview bundle
|
||||
// where core (Node.js-only) is excluded as external.
|
||||
export const authMethod = 'openai';
|
||||
|
||||
/**
|
||||
* Authenticate update notification (Qwen extension, not ACP spec).
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ describe('imageSupport browser bundling', () => {
|
|||
write: false,
|
||||
logLevel: 'silent',
|
||||
external: ['@qwen-code/qwen-code-core'],
|
||||
loader: {
|
||||
'.png': 'dataurl',
|
||||
},
|
||||
});
|
||||
|
||||
const output = result.outputFiles[0]?.text ?? '';
|
||||
|
|
|
|||
|
|
@ -281,9 +281,9 @@ export const App: React.FC = () => {
|
|||
// Account group
|
||||
const accountGroupItems: CompletionItem[] = [
|
||||
{
|
||||
id: 'login',
|
||||
label: 'Login',
|
||||
description: 'Login to Qwen Code',
|
||||
id: 'auth',
|
||||
label: '/auth',
|
||||
description: 'Configure Coding Plan or API Key',
|
||||
type: 'command',
|
||||
group: 'Account',
|
||||
},
|
||||
|
|
@ -697,9 +697,9 @@ export const App: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
if (itemId === 'login') {
|
||||
if (itemId === 'auth') {
|
||||
clearTriggerText();
|
||||
vscode.postMessage({ type: 'login', data: {} });
|
||||
vscode.postMessage({ type: 'auth', data: {} });
|
||||
completion.closeCompletion();
|
||||
return;
|
||||
}
|
||||
|
|
@ -1011,16 +1011,23 @@ export const App: React.FC = () => {
|
|||
>
|
||||
{!hasContent && !isLoading ? (
|
||||
isAuthenticated === false ? (
|
||||
<Onboarding
|
||||
onLogin={() => {
|
||||
vscode.postMessage({ type: 'login', data: {} });
|
||||
messageHandling.setWaitingForResponse(
|
||||
'Logging in to Qwen Code...',
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Onboarding />
|
||||
) : isAuthenticated === null ? (
|
||||
<EmptyState loadingMessage="Checking login status…" />
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<span
|
||||
className="inline-block w-6 h-6 animate-spin rounded-full border-2"
|
||||
style={{
|
||||
borderColor: 'var(--app-secondary-foreground)',
|
||||
borderTopColor: 'transparent',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--app-secondary-foreground)' }}
|
||||
>
|
||||
Preparing Qwen Code...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState isAuthenticated />
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { act } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('./ProviderSetupForm.js', () => ({
|
||||
ProviderSetupForm: () => <button type="button">Get Started</button>,
|
||||
}));
|
||||
|
||||
import { Onboarding } from './Onboarding.js';
|
||||
|
||||
describe('Onboarding', () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
document.body.removeAttribute('data-extension-uri');
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
it('renders the logo without requiring an extension URI on the body', () => {
|
||||
act(() => {
|
||||
root?.render(<Onboarding />);
|
||||
});
|
||||
|
||||
const logo = container?.querySelector('img[alt="Qwen Code"]');
|
||||
|
||||
expect(logo).toBeTruthy();
|
||||
expect(logo?.getAttribute('src')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -3,24 +3,74 @@
|
|||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* VSCode-specific Onboarding adapter
|
||||
* Uses webui Onboarding component with platform-specific icon URL
|
||||
* VSCode-specific Onboarding page.
|
||||
* Vertically centered welcome card with provider setup trigger.
|
||||
*/
|
||||
|
||||
import type { FC } from 'react';
|
||||
import { Onboarding as BaseOnboarding } from '@qwen-code/webui';
|
||||
import { generateIconUrl } from '../../utils/resourceUrl.js';
|
||||
|
||||
interface OnboardingPageProps {
|
||||
onLogin: () => void;
|
||||
}
|
||||
// eslint-disable-next-line import/no-internal-modules -- bundle the webview logo as a data URL
|
||||
import iconUrl from '../../../../assets/icon.png';
|
||||
import { ProviderSetupForm } from './ProviderSetupForm.js';
|
||||
|
||||
/**
|
||||
* VSCode Onboarding wrapper
|
||||
* Provides platform-specific icon URL to the webui Onboarding component
|
||||
* VSCode Onboarding page.
|
||||
*/
|
||||
export const Onboarding: FC<OnboardingPageProps> = ({ onLogin }) => {
|
||||
const iconUri = generateIconUrl('icon.png');
|
||||
export const Onboarding: FC = () => (
|
||||
<div
|
||||
className="flex flex-col flex-1 min-h-0 px-6"
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Logo + title block — sits above the card for visual breathing room */}
|
||||
<div className="flex flex-col items-center gap-3 mb-6">
|
||||
<img
|
||||
src={iconUrl}
|
||||
alt="Qwen Code"
|
||||
className="w-12 h-12 object-contain"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<h1
|
||||
className="text-base font-semibold"
|
||||
style={{ color: 'var(--app-primary-foreground)' }}
|
||||
>
|
||||
Qwen Code
|
||||
</h1>
|
||||
<p
|
||||
className="text-xs mt-1"
|
||||
style={{ color: 'var(--app-secondary-foreground)' }}
|
||||
>
|
||||
AI-powered coding assistant for your editor
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
return <BaseOnboarding iconUrl={iconUri} onGetStarted={onLogin} />;
|
||||
};
|
||||
{/* Setup card */}
|
||||
<div
|
||||
className="w-full max-w-[300px] rounded-lg border p-4"
|
||||
style={{
|
||||
backgroundColor: 'var(--app-input-secondary-background)',
|
||||
borderColor: 'var(--app-input-border)',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-xs mb-3 text-center"
|
||||
style={{ color: 'var(--app-secondary-foreground)' }}
|
||||
>
|
||||
Connect a model provider to get started
|
||||
</p>
|
||||
<ProviderSetupForm />
|
||||
</div>
|
||||
|
||||
{/* Subtle hint below the card */}
|
||||
<p
|
||||
className="text-[10px] mt-4 text-center max-w-[260px]"
|
||||
style={{ color: 'var(--app-secondary-foreground)', opacity: 0.6 }}
|
||||
>
|
||||
Supports Alibaba Cloud Coding Plan, ModelStudio API Key, and
|
||||
OpenAI-compatible endpoints
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { act } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const { mockPostMessage } = vi.hoisted(() => ({
|
||||
mockPostMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useVSCode.js', () => ({
|
||||
useVSCode: () => ({
|
||||
postMessage: mockPostMessage,
|
||||
getState: vi.fn(),
|
||||
setState: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ProviderSetupForm } from './ProviderSetupForm.js';
|
||||
|
||||
describe('ProviderSetupForm', () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
it('leaves connecting state when auth flow is cancelled', () => {
|
||||
act(() => {
|
||||
root?.render(<ProviderSetupForm />);
|
||||
});
|
||||
|
||||
const button = container?.querySelector('button');
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(mockPostMessage).toHaveBeenCalledWith({ type: 'auth' });
|
||||
expect(container?.textContent).toContain('Connecting...');
|
||||
expect(button?.hasAttribute('disabled')).toBe(true);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: { type: 'authCancelled' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain('Get Started');
|
||||
expect(button?.hasAttribute('disabled')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Provider Setup — triggers the auth interactive flow (QuickPick + InputBox).
|
||||
*/
|
||||
|
||||
import { useState, useEffect, type FC } from 'react';
|
||||
import { useVSCode } from '../../hooks/useVSCode.js';
|
||||
|
||||
/**
|
||||
* Small rotating spinner for loading states.
|
||||
*/
|
||||
const Spinner: FC<{ size?: number }> = ({ size = 14 }) => (
|
||||
<span
|
||||
className="inline-block animate-spin rounded-full border-2 border-current"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderTopColor: 'transparent',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
/**
|
||||
* ProviderSetupForm — Single button that launches the interactive auth flow.
|
||||
*/
|
||||
export const ProviderSetupForm: FC = () => {
|
||||
const vscode = useVSCode();
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
const msg = event.data;
|
||||
if (msg?.type === 'authError' || msg?.type === 'agentConnectionError') {
|
||||
setIsConnecting(false);
|
||||
setError(
|
||||
msg.data?.message || 'Connection failed. Check your settings.',
|
||||
);
|
||||
}
|
||||
if (msg?.type === 'authCancelled') {
|
||||
setIsConnecting(false);
|
||||
setError(null);
|
||||
}
|
||||
if (msg?.type === 'authSuccess' || msg?.type === 'agentConnected') {
|
||||
setIsConnecting(false);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
}, []);
|
||||
|
||||
const handleGetStarted = () => {
|
||||
setError(null);
|
||||
setIsConnecting(true);
|
||||
vscode.postMessage({ type: 'auth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<button
|
||||
onClick={handleGetStarted}
|
||||
disabled={isConnecting}
|
||||
className="w-full py-2 rounded-md text-[13px] font-medium flex items-center justify-center gap-2 transition-all"
|
||||
style={{
|
||||
backgroundColor: isConnecting
|
||||
? 'var(--app-input-secondary-background)'
|
||||
: 'var(--app-primary, var(--app-button-background))',
|
||||
color: isConnecting
|
||||
? 'var(--app-secondary-foreground)'
|
||||
: 'var(--app-button-foreground, #fff)',
|
||||
cursor: isConnecting ? 'not-allowed' : 'pointer',
|
||||
border: isConnecting
|
||||
? '1px solid var(--app-input-border)'
|
||||
: '1px solid transparent',
|
||||
}}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Spinner />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Get Started
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4.5 2.5L8 6L4.5 9.5" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="text-[11px] leading-snug px-2.5 py-2 rounded"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, #ef4444 10%, transparent)',
|
||||
color: '#f87171',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -97,10 +97,10 @@ export const VSCodePlatformProvider: FC<VSCodePlatformProviderProps> = ({
|
|||
});
|
||||
}, [vscode]);
|
||||
|
||||
// Login handler
|
||||
// Auth handler
|
||||
const login = useCallback(() => {
|
||||
vscode.postMessage({
|
||||
type: 'login',
|
||||
type: 'auth',
|
||||
data: {},
|
||||
});
|
||||
}, [vscode]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockShowInputBox, mockShowQuickPick } = vi.hoisted(() => ({
|
||||
mockShowInputBox: vi.fn(),
|
||||
mockShowQuickPick: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
window: {
|
||||
showQuickPick: mockShowQuickPick,
|
||||
showInputBox: mockShowInputBox,
|
||||
},
|
||||
}));
|
||||
|
||||
import { AuthMessageHandler } from './AuthMessageHandler.js';
|
||||
|
||||
describe('AuthMessageHandler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sends authCancelled when the provider picker is dismissed', async () => {
|
||||
mockShowQuickPick.mockResolvedValue(undefined);
|
||||
const sendToWebView = vi.fn();
|
||||
const handler = new AuthMessageHandler(
|
||||
{} as never,
|
||||
{} as never,
|
||||
null,
|
||||
sendToWebView,
|
||||
);
|
||||
|
||||
await handler.handle({ type: 'auth' });
|
||||
|
||||
expect(sendToWebView).toHaveBeenCalledWith({ type: 'authCancelled' });
|
||||
});
|
||||
|
||||
it('sends authCancelled when the api key input is dismissed mid-flow', async () => {
|
||||
mockShowQuickPick
|
||||
.mockResolvedValueOnce({ value: 'coding-plan' })
|
||||
.mockResolvedValueOnce({ value: 'china' });
|
||||
mockShowInputBox.mockResolvedValue(undefined);
|
||||
|
||||
const sendToWebView = vi.fn();
|
||||
const handler = new AuthMessageHandler(
|
||||
{} as never,
|
||||
{} as never,
|
||||
null,
|
||||
sendToWebView,
|
||||
);
|
||||
|
||||
await handler.handle({ type: 'auth' });
|
||||
|
||||
expect(sendToWebView).toHaveBeenCalledWith({ type: 'authCancelled' });
|
||||
});
|
||||
});
|
||||
|
|
@ -13,22 +13,30 @@ import { getErrorMessage } from '../../utils/errorMessage.js';
|
|||
* Handles all authentication-related messages
|
||||
*/
|
||||
export class AuthMessageHandler extends BaseMessageHandler {
|
||||
private loginHandler: (() => Promise<void>) | null = null;
|
||||
private authInteractiveHandler:
|
||||
| ((
|
||||
provider: string,
|
||||
region?: string,
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
model?: string,
|
||||
modelIds?: string,
|
||||
) => Promise<void>)
|
||||
| null = null;
|
||||
|
||||
canHandle(messageType: string): boolean {
|
||||
return ['login', 'getAccountInfo'].includes(messageType);
|
||||
return ['auth', 'getAccountInfo'].includes(messageType);
|
||||
}
|
||||
|
||||
async handle(message: { type: string; data?: unknown }): Promise<void> {
|
||||
switch (message.type) {
|
||||
case 'login':
|
||||
await this.handleLogin();
|
||||
case 'auth':
|
||||
await this.handleAuthInteractive();
|
||||
break;
|
||||
|
||||
case 'getAccountInfo': {
|
||||
case 'getAccountInfo':
|
||||
await this.handleGetAccountInfo();
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn(
|
||||
|
|
@ -40,14 +48,23 @@ export class AuthMessageHandler extends BaseMessageHandler {
|
|||
}
|
||||
|
||||
/**
|
||||
* Set login handler
|
||||
* Set auth interactive handler — interactive auth flow.
|
||||
*/
|
||||
setLoginHandler(handler: () => Promise<void>): void {
|
||||
this.loginHandler = handler;
|
||||
setAuthInteractiveHandler(
|
||||
handler: (
|
||||
provider: string,
|
||||
region?: string,
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
model?: string,
|
||||
modelIds?: string,
|
||||
) => Promise<void>,
|
||||
): void {
|
||||
this.authInteractiveHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle getAccountInfo request - queries ACP for live account info
|
||||
* Handle getAccountInfo request
|
||||
*/
|
||||
private async handleGetAccountInfo(): Promise<void> {
|
||||
try {
|
||||
|
|
@ -71,45 +88,300 @@ export class AuthMessageHandler extends BaseMessageHandler {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle login request
|
||||
*/
|
||||
private async handleLogin(): Promise<void> {
|
||||
try {
|
||||
console.log('[AuthMessageHandler] Login requested');
|
||||
console.log(
|
||||
'[AuthMessageHandler] Login handler available:',
|
||||
!!this.loginHandler,
|
||||
);
|
||||
// ---------------------------------------------------------------------------
|
||||
// auth: Interactive auth flow (mirrors CLI's /auth)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Direct login without additional confirmation
|
||||
if (this.loginHandler) {
|
||||
console.log('[AuthMessageHandler] Calling login handler');
|
||||
await this.loginHandler();
|
||||
console.log(
|
||||
'[AuthMessageHandler] Login handler completed successfully',
|
||||
);
|
||||
// Alibaba Standard API Key region endpoints
|
||||
private static readonly ALIBABA_STANDARD_ENDPOINTS: Record<string, string> = {
|
||||
'cn-beijing': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
'sg-singapore': 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
'us-virginia': 'https://dashscope-us.aliyuncs.com/compatible-mode/v1',
|
||||
'cn-hongkong':
|
||||
'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
};
|
||||
|
||||
/**
|
||||
* Notify the webview that the interactive auth flow was dismissed.
|
||||
*/
|
||||
private notifyAuthCancelled(): void {
|
||||
this.sendToWebView({ type: 'authCancelled' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: show a QuickPick and return the selected item's `value`.
|
||||
* Returns undefined if the user cancels.
|
||||
*/
|
||||
private async pick<T extends string>(
|
||||
items: Array<{ label: string; description?: string; value: T }>,
|
||||
title: string,
|
||||
placeHolder: string,
|
||||
): Promise<T | undefined> {
|
||||
const choice = await vscode.window.showQuickPick(items, {
|
||||
title,
|
||||
placeHolder,
|
||||
});
|
||||
if (!choice) {
|
||||
this.notifyAuthCancelled();
|
||||
return undefined;
|
||||
}
|
||||
return (choice as { value: T }).value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: show an InputBox. Returns undefined if the user cancels.
|
||||
*/
|
||||
private async input(opts: {
|
||||
title: string;
|
||||
prompt: string;
|
||||
placeHolder?: string;
|
||||
value?: string;
|
||||
password?: boolean;
|
||||
required?: boolean;
|
||||
}): Promise<string | undefined> {
|
||||
const value = await vscode.window.showInputBox({
|
||||
title: opts.title,
|
||||
prompt: opts.prompt,
|
||||
placeHolder: opts.placeHolder,
|
||||
value: opts.value,
|
||||
password: opts.password ?? false,
|
||||
validateInput: opts.required
|
||||
? (v) => (!v?.trim() ? 'This field is required' : null)
|
||||
: undefined,
|
||||
});
|
||||
if (value === undefined) {
|
||||
this.notifyAuthCancelled();
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle auth — full interactive auth flow.
|
||||
*
|
||||
* Tree (mirrors CLI AuthDialog):
|
||||
* |- Coding Plan -> Region (China/Global) -> API Key -> done
|
||||
* \- API Key
|
||||
* |- Alibaba Standard -> Region (4 regions) -> API Key -> Model IDs -> done
|
||||
* \- Custom -> Base URL -> API Key -> Model -> done
|
||||
*/
|
||||
private async handleAuthInteractive(): Promise<void> {
|
||||
try {
|
||||
// Main menu
|
||||
const provider = await this.pick(
|
||||
[
|
||||
{
|
||||
label: 'Alibaba Cloud Coding Plan',
|
||||
description:
|
||||
'Paid · Up to 6,000 requests/5 hrs · All Coding Plan Models',
|
||||
value: 'coding-plan' as const,
|
||||
},
|
||||
{
|
||||
label: 'API Key',
|
||||
description: 'Bring your own API key',
|
||||
value: 'api-key' as const,
|
||||
},
|
||||
],
|
||||
'Qwen Code: Auth',
|
||||
'Select authentication method',
|
||||
);
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'coding-plan') {
|
||||
await this.authCodingPlan();
|
||||
} else {
|
||||
console.log('[AuthMessageHandler] Using fallback login method');
|
||||
// Fallback: show message and use command
|
||||
vscode.window.showInformationMessage(
|
||||
'Please wait while we connect to Qwen Code...',
|
||||
);
|
||||
await vscode.commands.executeCommand('qwen-code.login');
|
||||
await this.authApiKey();
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
console.error('[AuthMessageHandler] Login failed:', error);
|
||||
console.error(
|
||||
'[AuthMessageHandler] Error stack:',
|
||||
error instanceof Error ? error.stack : 'N/A',
|
||||
);
|
||||
console.error('[AuthMessageHandler] auth failed:', error);
|
||||
this.sendToWebView({
|
||||
type: 'loginError',
|
||||
data: {
|
||||
message: `Login failed: ${errorMsg}`,
|
||||
},
|
||||
type: 'authError',
|
||||
data: { message: `Auth failed: ${errorMsg}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coding Plan: region -> API key -> connect.
|
||||
*/
|
||||
private async authCodingPlan(): Promise<void> {
|
||||
const region = await this.pick(
|
||||
[
|
||||
{
|
||||
label: '中国 (China)',
|
||||
description: '阿里云百炼 — aliyun.com',
|
||||
value: 'china' as const,
|
||||
},
|
||||
{
|
||||
label: 'Global',
|
||||
description: 'Alibaba Cloud — alibabacloud.com',
|
||||
value: 'global' as const,
|
||||
},
|
||||
],
|
||||
'Qwen Code: Coding Plan Region',
|
||||
'Select region',
|
||||
);
|
||||
if (!region) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = await this.input({
|
||||
title: 'Qwen Code: API Key',
|
||||
prompt: 'Enter your Coding Plan API key',
|
||||
placeHolder: 'sk-...',
|
||||
password: true,
|
||||
required: true,
|
||||
});
|
||||
if (!apiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.authInteractiveHandler) {
|
||||
await this.authInteractiveHandler('coding-plan', region, apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key: select type -> Alibaba Standard or Custom.
|
||||
*/
|
||||
private async authApiKey(): Promise<void> {
|
||||
const keyType = await this.pick(
|
||||
[
|
||||
{
|
||||
label: 'Alibaba Cloud ModelStudio Standard API Key',
|
||||
description: 'Quick setup for Model Studio (China/International)',
|
||||
value: 'alibaba-standard' as const,
|
||||
},
|
||||
{
|
||||
label: 'Custom API Key',
|
||||
description:
|
||||
'For other OpenAI / Anthropic / Gemini-compatible providers',
|
||||
value: 'custom' as const,
|
||||
},
|
||||
],
|
||||
'Qwen Code: Select API Key Type',
|
||||
'Select API key type',
|
||||
);
|
||||
if (!keyType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyType === 'alibaba-standard') {
|
||||
await this.authAlibabaStandard();
|
||||
} else {
|
||||
await this.authCustom();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alibaba Standard: region -> API key -> model IDs -> connect.
|
||||
*/
|
||||
private async authAlibabaStandard(): Promise<void> {
|
||||
const endpoints = AuthMessageHandler.ALIBABA_STANDARD_ENDPOINTS;
|
||||
|
||||
const region = await this.pick(
|
||||
Object.entries(endpoints).map(([key, endpoint]) => ({
|
||||
label:
|
||||
key === 'cn-beijing'
|
||||
? 'China (Beijing)'
|
||||
: key === 'sg-singapore'
|
||||
? 'Singapore'
|
||||
: key === 'us-virginia'
|
||||
? 'US (Virginia)'
|
||||
: 'China (Hong Kong)',
|
||||
description: `Endpoint: ${endpoint}`,
|
||||
value: key,
|
||||
})),
|
||||
'Qwen Code: Select Region',
|
||||
'Select region for Alibaba Cloud ModelStudio',
|
||||
);
|
||||
if (!region) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = await this.input({
|
||||
title: 'Qwen Code: API Key',
|
||||
prompt: 'Enter your Alibaba Cloud ModelStudio API key',
|
||||
placeHolder: 'sk-...',
|
||||
password: true,
|
||||
required: true,
|
||||
});
|
||||
if (!apiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelIds = await this.input({
|
||||
title: 'Qwen Code: Model IDs',
|
||||
prompt: 'Enter model IDs (comma-separated)',
|
||||
placeHolder: 'qwen3.5-plus,glm-5,kimi-k2.5',
|
||||
value: 'qwen3.5-plus',
|
||||
required: true,
|
||||
});
|
||||
if (!modelIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = endpoints[region] || endpoints['cn-beijing'];
|
||||
const firstModel = modelIds.split(',')[0]?.trim() || 'qwen3.5-plus';
|
||||
|
||||
if (this.authInteractiveHandler) {
|
||||
await this.authInteractiveHandler(
|
||||
'alibaba-standard',
|
||||
region,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
firstModel,
|
||||
modelIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom: base URL -> API key -> model -> connect.
|
||||
*/
|
||||
private async authCustom(): Promise<void> {
|
||||
const baseUrl = await this.input({
|
||||
title: 'Qwen Code: Base URL',
|
||||
prompt: 'Enter API base URL',
|
||||
placeHolder: 'https://api.openai.com/v1',
|
||||
value: 'https://api.openai.com/v1',
|
||||
});
|
||||
if (baseUrl === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = await this.input({
|
||||
title: 'Qwen Code: API Key',
|
||||
prompt: 'Enter your API key',
|
||||
placeHolder: 'sk-...',
|
||||
password: true,
|
||||
required: true,
|
||||
});
|
||||
if (!apiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = await this.input({
|
||||
title: 'Qwen Code: Model',
|
||||
prompt: 'Enter model name',
|
||||
placeHolder: 'gpt-4o',
|
||||
required: true,
|
||||
});
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.authInteractiveHandler) {
|
||||
await this.authInteractiveHandler(
|
||||
'api-key',
|
||||
undefined,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,11 +165,26 @@ export class MessageRouter {
|
|||
}
|
||||
|
||||
/**
|
||||
* Set login handler
|
||||
* Set auth interactive handler — interactive auth flow.
|
||||
* Also registers the handler on the session handler so
|
||||
* "Configure" prompts in session flows trigger the interactive flow.
|
||||
*/
|
||||
setLoginHandler(handler: () => Promise<void>): void {
|
||||
this.authHandler.setLoginHandler(handler);
|
||||
this.sessionHandler?.setLoginHandler?.(handler);
|
||||
setAuthInteractiveHandler(
|
||||
handler: (
|
||||
provider: string,
|
||||
region?: string,
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
model?: string,
|
||||
modelIds?: string,
|
||||
) => Promise<void>,
|
||||
): void {
|
||||
this.authHandler.setAuthInteractiveHandler(handler);
|
||||
// SessionMessageHandler's authHandler is a simple () => Promise<void>.
|
||||
// Wrap so "Configure" prompts trigger the full interactive auth QuickPick.
|
||||
this.sessionHandler?.setAuthHandler?.(() =>
|
||||
this.authHandler.handle({ type: 'auth' }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { getErrorMessage } from '../../utils/errorMessage.js';
|
|||
*/
|
||||
export class SessionMessageHandler extends BaseMessageHandler {
|
||||
private currentStreamContent = '';
|
||||
private loginHandler: (() => Promise<void>) | null = null;
|
||||
private authHandler: (() => Promise<void>) | null = null;
|
||||
private isTitleSet = false; // Flag to track if title has been set
|
||||
|
||||
canHandle(messageType: string): boolean {
|
||||
|
|
@ -42,10 +42,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
}
|
||||
|
||||
/**
|
||||
* Set login handler
|
||||
* Set auth handler
|
||||
*/
|
||||
setLoginHandler(handler: () => Promise<void>): void {
|
||||
this.loginHandler = handler;
|
||||
setAuthHandler(handler: () => Promise<void>): void {
|
||||
this.authHandler = handler;
|
||||
}
|
||||
|
||||
async handle(message: { type: string; data?: unknown }): Promise<void> {
|
||||
|
|
@ -223,16 +223,16 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
}
|
||||
|
||||
/**
|
||||
* Prompt user to login and invoke the registered login handler/command.
|
||||
* Returns true if a login was initiated.
|
||||
* Prompt user to authenticate and invoke the registered auth handler/command.
|
||||
* Returns true if authentication was initiated.
|
||||
*/
|
||||
private async promptLogin(message: string): Promise<boolean> {
|
||||
const result = await vscode.window.showWarningMessage(message, 'Login Now');
|
||||
if (result === 'Login Now') {
|
||||
if (this.loginHandler) {
|
||||
await this.loginHandler();
|
||||
private async promptAuth(message: string): Promise<boolean> {
|
||||
const result = await vscode.window.showWarningMessage(message, 'Configure');
|
||||
if (result === 'Configure') {
|
||||
if (this.authHandler) {
|
||||
await this.authHandler();
|
||||
} else {
|
||||
await vscode.commands.executeCommand('qwen-code.login');
|
||||
await vscode.commands.executeCommand('qwen-code.auth');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -240,25 +240,25 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
}
|
||||
|
||||
/**
|
||||
* Prompt user to login or view offline. Returns 'login', 'offline', or 'dismiss'.
|
||||
* When login is chosen, it triggers the login handler/command.
|
||||
* Prompt user to authenticate or view offline. Returns 'auth', 'offline', or 'dismiss'.
|
||||
* When configure is chosen, it triggers the auth handler/command.
|
||||
*/
|
||||
private async promptLoginOrOffline(
|
||||
private async promptAuthOrOffline(
|
||||
message: string,
|
||||
): Promise<'login' | 'offline' | 'dismiss'> {
|
||||
): Promise<'auth' | 'offline' | 'dismiss'> {
|
||||
const selection = await vscode.window.showWarningMessage(
|
||||
message,
|
||||
'Login Now',
|
||||
'Configure',
|
||||
'View Offline',
|
||||
);
|
||||
|
||||
if (selection === 'Login Now') {
|
||||
if (this.loginHandler) {
|
||||
await this.loginHandler();
|
||||
if (selection === 'Configure') {
|
||||
if (this.authHandler) {
|
||||
await this.authHandler();
|
||||
} else {
|
||||
await vscode.commands.executeCommand('qwen-code.login');
|
||||
await vscode.commands.executeCommand('qwen-code.auth');
|
||||
}
|
||||
return 'login';
|
||||
return 'auth';
|
||||
}
|
||||
if (selection === 'View Offline') {
|
||||
return 'offline';
|
||||
|
|
@ -270,7 +270,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
return getErrorMessage(error);
|
||||
}
|
||||
|
||||
private shouldPromptLogin(error: unknown): boolean {
|
||||
private shouldPromptAuth(error: unknown): boolean {
|
||||
return isAuthenticationRequiredError(error);
|
||||
}
|
||||
|
||||
|
|
@ -424,8 +424,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
if (!this.agentManager.isConnected) {
|
||||
console.warn('[SessionMessageHandler] Agent not connected');
|
||||
|
||||
// Show non-modal notification with Login button
|
||||
await this.promptLogin('You need to login first to use Qwen Code.');
|
||||
// Show non-modal notification with Configure button
|
||||
await this.promptAuth(
|
||||
'You need to configure your provider to use Qwen Code.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -441,9 +443,9 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
createErr,
|
||||
);
|
||||
const errorMsg = this.getErrorMessage(createErr);
|
||||
if (this.shouldPromptLogin(createErr)) {
|
||||
await this.promptLogin(
|
||||
'Your login session has expired or is invalid. Please login again to continue using Qwen Code.',
|
||||
if (this.shouldPromptAuth(createErr)) {
|
||||
await this.promptAuth(
|
||||
'Your session has expired or is invalid. Please configure your provider to continue using Qwen Code.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -522,17 +524,17 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
// Check for session not found error and handle it appropriately
|
||||
if (
|
||||
errorMsg.includes('Session not found') ||
|
||||
this.shouldPromptLogin(error)
|
||||
this.shouldPromptAuth(error)
|
||||
) {
|
||||
// Show a more user-friendly error message for expired sessions
|
||||
await this.promptLogin(
|
||||
'Your login session has expired or is invalid. Please login again to continue using Qwen Code.',
|
||||
await this.promptAuth(
|
||||
'Your session has expired or is invalid. Please configure your provider to continue using Qwen Code.',
|
||||
);
|
||||
|
||||
// Send a specific error to the webview for better UI handling
|
||||
this.sendToWebView({
|
||||
type: 'sessionExpired',
|
||||
data: { message: 'Session expired. Please login again.' },
|
||||
data: { message: 'Session expired. Please authenticate again.' },
|
||||
});
|
||||
this.sendStreamEnd('session_expired', myRequestId);
|
||||
} else {
|
||||
|
|
@ -578,10 +580,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
try {
|
||||
console.log('[SessionMessageHandler] Creating new Qwen session...');
|
||||
|
||||
// Ensure connection (login) before creating a new session
|
||||
// Ensure connection (auth) before creating a new session
|
||||
if (!this.agentManager.isConnected) {
|
||||
const proceeded = await this.promptLogin(
|
||||
'You need to login before creating a new session.',
|
||||
const proceeded = await this.promptAuth(
|
||||
'You need to configure your provider before creating a new session.',
|
||||
);
|
||||
if (!proceeded) {
|
||||
return;
|
||||
|
|
@ -610,16 +612,16 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
// Safely convert error to string
|
||||
const errorMsg = this.getErrorMessage(error);
|
||||
// Check for authentication/session expiration errors
|
||||
if (this.shouldPromptLogin(error)) {
|
||||
if (this.shouldPromptAuth(error)) {
|
||||
// Show a more user-friendly error message for expired sessions
|
||||
await this.promptLogin(
|
||||
'Your login session has expired or is invalid. Please login again to create a new session.',
|
||||
await this.promptAuth(
|
||||
'Your session has expired or is invalid. Please configure your provider to create a new session.',
|
||||
);
|
||||
|
||||
// Send a specific error to the webview for better UI handling
|
||||
this.sendToWebView({
|
||||
type: 'sessionExpired',
|
||||
data: { message: 'Session expired. Please login again.' },
|
||||
data: { message: 'Session expired. Please authenticate again.' },
|
||||
});
|
||||
} else {
|
||||
this.sendToWebView({
|
||||
|
|
@ -637,10 +639,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
try {
|
||||
console.log('[SessionMessageHandler] Switching to session:', sessionId);
|
||||
|
||||
// If not connected yet, offer to login or view offline
|
||||
// If not connected yet, offer to authenticate or view offline
|
||||
if (!this.agentManager.isConnected) {
|
||||
const choice = await this.promptLoginOrOffline(
|
||||
'You are not logged in. Login now to fully restore this session, or view it offline.',
|
||||
const choice = await this.promptAuthOrOffline(
|
||||
'You are not authenticated. Configure your provider to fully restore this session, or view it offline.',
|
||||
);
|
||||
|
||||
if (choice === 'offline') {
|
||||
|
|
@ -653,10 +655,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
data: { sessionId, messages },
|
||||
});
|
||||
vscode.window.showInformationMessage(
|
||||
'Showing cached session content. Login to interact with the AI.',
|
||||
'Showing cached session content. Configure your provider to interact with the AI.',
|
||||
);
|
||||
return;
|
||||
} else if (choice !== 'login') {
|
||||
} else if (choice !== 'auth') {
|
||||
// User dismissed; do nothing
|
||||
return;
|
||||
}
|
||||
|
|
@ -711,16 +713,16 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
);
|
||||
|
||||
// Check for authentication/session expiration errors
|
||||
if (this.shouldPromptLogin(loadError)) {
|
||||
if (this.shouldPromptAuth(loadError)) {
|
||||
// Show a more user-friendly error message for expired sessions
|
||||
await this.promptLogin(
|
||||
'Your login session has expired or is invalid. Please login again to switch sessions.',
|
||||
await this.promptAuth(
|
||||
'Your session has expired or is invalid. Please configure your provider to switch sessions.',
|
||||
);
|
||||
|
||||
// Send a specific error to the webview for better UI handling
|
||||
this.sendToWebView({
|
||||
type: 'sessionExpired',
|
||||
data: { message: 'Session expired. Please login again.' },
|
||||
data: { message: 'Session expired. Please authenticate again.' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -765,16 +767,18 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
);
|
||||
|
||||
// Check for authentication/session expiration errors in session creation
|
||||
if (this.shouldPromptLogin(createError)) {
|
||||
if (this.shouldPromptAuth(createError)) {
|
||||
// Show a more user-friendly error message for expired sessions
|
||||
await this.promptLogin(
|
||||
'Your login session has expired or is invalid. Please login again to switch sessions.',
|
||||
await this.promptAuth(
|
||||
'Your session has expired or is invalid. Please configure your provider to switch sessions.',
|
||||
);
|
||||
|
||||
// Send a specific error to the webview for better UI handling
|
||||
this.sendToWebView({
|
||||
type: 'sessionExpired',
|
||||
data: { message: 'Session expired. Please login again.' },
|
||||
data: {
|
||||
message: 'Session expired. Please authenticate again.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -789,7 +793,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
data: { sessionId, messages, session: sessionDetails },
|
||||
});
|
||||
vscode.window.showWarningMessage(
|
||||
'Showing cached session content. Login to interact with the AI.',
|
||||
'Showing cached session content. Configure your provider to interact with the AI.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -799,16 +803,16 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
// Safely convert error to string
|
||||
const errorMsg = this.getErrorMessage(error);
|
||||
// Check for authentication/session expiration errors
|
||||
if (this.shouldPromptLogin(error)) {
|
||||
if (this.shouldPromptAuth(error)) {
|
||||
// Show a more user-friendly error message for expired sessions
|
||||
await this.promptLogin(
|
||||
'Your login session has expired or is invalid. Please login again to switch sessions.',
|
||||
await this.promptAuth(
|
||||
'Your session has expired or is invalid. Please configure your provider to switch sessions.',
|
||||
);
|
||||
|
||||
// Send a specific error to the webview for better UI handling
|
||||
this.sendToWebView({
|
||||
type: 'sessionExpired',
|
||||
data: { message: 'Session expired. Please login again.' },
|
||||
data: { message: 'Session expired. Please authenticate again.' },
|
||||
});
|
||||
} else {
|
||||
this.sendToWebView({
|
||||
|
|
@ -848,16 +852,16 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
// Safely convert error to string
|
||||
const errorMsg = this.getErrorMessage(error);
|
||||
// Check for authentication/session expiration errors
|
||||
if (this.shouldPromptLogin(error)) {
|
||||
if (this.shouldPromptAuth(error)) {
|
||||
// Show a more user-friendly error message for expired sessions
|
||||
await this.promptLogin(
|
||||
'Your login session has expired or is invalid. Please login again to view sessions.',
|
||||
await this.promptAuth(
|
||||
'Your session has expired or is invalid. Please configure your provider to view sessions.',
|
||||
);
|
||||
|
||||
// Send a specific error to the webview for better UI handling
|
||||
this.sendToWebView({
|
||||
type: 'sessionExpired',
|
||||
data: { message: 'Session expired. Please login again.' },
|
||||
data: { message: 'Session expired. Please authenticate again.' },
|
||||
});
|
||||
} else {
|
||||
this.sendToWebView({
|
||||
|
|
@ -895,10 +899,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
*/
|
||||
private async handleResumeSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
// If not connected, offer to login or view offline
|
||||
// If not connected, offer to authenticate or view offline
|
||||
if (!this.agentManager.isConnected) {
|
||||
const choice = await this.promptLoginOrOffline(
|
||||
'You are not logged in. Login now to fully restore this session, or view it offline.',
|
||||
const choice = await this.promptAuthOrOffline(
|
||||
'You are not authenticated. Configure your provider to fully restore this session, or view it offline.',
|
||||
);
|
||||
|
||||
if (choice === 'offline') {
|
||||
|
|
@ -910,10 +914,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
data: { sessionId, messages },
|
||||
});
|
||||
vscode.window.showInformationMessage(
|
||||
'Showing cached session content. Login to interact with the AI.',
|
||||
'Showing cached session content. Configure your provider to interact with the AI.',
|
||||
);
|
||||
return;
|
||||
} else if (choice !== 'login') {
|
||||
} else if (choice !== 'auth') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -937,16 +941,16 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
return;
|
||||
} catch (acpError) {
|
||||
// Check for authentication/session expiration errors
|
||||
if (this.shouldPromptLogin(acpError)) {
|
||||
if (this.shouldPromptAuth(acpError)) {
|
||||
// Show a more user-friendly error message for expired sessions
|
||||
await this.promptLogin(
|
||||
'Your login session has expired or is invalid. Please login again to resume sessions.',
|
||||
await this.promptAuth(
|
||||
'Your session has expired or is invalid. Please configure your provider to resume sessions.',
|
||||
);
|
||||
|
||||
// Send a specific error to the webview for better UI handling
|
||||
this.sendToWebView({
|
||||
type: 'sessionExpired',
|
||||
data: { message: 'Session expired. Please login again.' },
|
||||
data: { message: 'Session expired. Please authenticate again.' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -959,16 +963,16 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
// Safely convert error to string
|
||||
const errorMsg = this.getErrorMessage(error);
|
||||
// Check for authentication/session expiration errors
|
||||
if (this.shouldPromptLogin(error)) {
|
||||
if (this.shouldPromptAuth(error)) {
|
||||
// Show a more user-friendly error message for expired sessions
|
||||
await this.promptLogin(
|
||||
'Your login session has expired or is invalid. Please login again to resume sessions.',
|
||||
await this.promptAuth(
|
||||
'Your session has expired or is invalid. Please configure your provider to resume sessions.',
|
||||
);
|
||||
|
||||
// Send a specific error to the webview for better UI handling
|
||||
this.sendToWebView({
|
||||
type: 'sessionExpired',
|
||||
data: { message: 'Session expired. Please login again.' },
|
||||
data: { message: 'Session expired. Please authenticate again.' },
|
||||
});
|
||||
} else {
|
||||
this.sendToWebView({
|
||||
|
|
|
|||
|
|
@ -110,12 +110,12 @@ export const useMessageSubmit = ({
|
|||
inputFieldRef.current.setAttribute('data-empty', 'true');
|
||||
}
|
||||
vscode.postMessage({
|
||||
type: 'login',
|
||||
type: 'auth',
|
||||
data: {},
|
||||
});
|
||||
// Show a friendly loading message in the chat while logging in
|
||||
// Show a friendly loading message in the chat while authenticating
|
||||
try {
|
||||
messageHandling.setWaitingForResponse('Logging in to Qwen Code...');
|
||||
messageHandling.setWaitingForResponse('Authenticating Qwen Code...');
|
||||
} catch (_err) {
|
||||
// Best-effort UI hint; ignore if hook not available
|
||||
}
|
||||
|
|
|
|||
|
|
@ -456,15 +456,8 @@ export const useWebViewMessages = ({
|
|||
break;
|
||||
}
|
||||
|
||||
case 'loginSuccess': {
|
||||
// Clear loading state and show a short assistant notice
|
||||
case 'authSuccess': {
|
||||
handlers.messageHandling.clearWaitingForResponse();
|
||||
handlers.messageHandling.addMessage({
|
||||
role: 'assistant',
|
||||
content: 'Successfully logged in. You can continue chatting.',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// Set authentication state to true
|
||||
handlers.setIsAuthenticated?.(true);
|
||||
break;
|
||||
}
|
||||
|
|
@ -494,12 +487,12 @@ export const useWebViewMessages = ({
|
|||
break;
|
||||
}
|
||||
|
||||
case 'loginError': {
|
||||
case 'authError': {
|
||||
// Clear loading state and show error notice
|
||||
handlers.messageHandling.clearWaitingForResponse();
|
||||
const errorMsg =
|
||||
(message?.data?.message as string) ||
|
||||
'Login failed. Please try again.';
|
||||
'Auth failed. Please try again.';
|
||||
handlers.messageHandling.addMessage({
|
||||
role: 'assistant',
|
||||
content: errorMsg,
|
||||
|
|
|
|||
|
|
@ -75,10 +75,19 @@ export class MessageHandler {
|
|||
}
|
||||
|
||||
/**
|
||||
* Set login handler
|
||||
* Set auth interactive handler — interactive auth flow.
|
||||
*/
|
||||
setLoginHandler(handler: () => Promise<void>): void {
|
||||
this.router.setLoginHandler(handler);
|
||||
setAuthInteractiveHandler(
|
||||
handler: (
|
||||
provider: string,
|
||||
region?: string,
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
model?: string,
|
||||
modelIds?: string,
|
||||
) => Promise<void>,
|
||||
): void {
|
||||
this.router.setAuthInteractiveHandler(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,23 +7,36 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
mockConfigChangeHandlers,
|
||||
availableCommandsCallbackRef,
|
||||
mockCreateImagePathResolver,
|
||||
mockConfigGet,
|
||||
mockConfigUpdate,
|
||||
mockGetGlobalTempDir,
|
||||
mockGetPanel,
|
||||
mockMessageHandlerInstances,
|
||||
mockOnDidChangeConfiguration,
|
||||
mockOnDidChangeActiveTextEditor,
|
||||
mockOnDidChangeTextEditorSelection,
|
||||
mockOpenExternal,
|
||||
mockReadQwenSettingsForVSCode,
|
||||
mockWriteCodingPlanConfig,
|
||||
mockWriteModelProvidersConfig,
|
||||
mockClearPersistedAuth,
|
||||
slashCommandNotificationCallbackRef,
|
||||
mockQwenAgentManagerInstances,
|
||||
} = vi.hoisted(() => ({
|
||||
mockConfigChangeHandlers: [] as Array<
|
||||
(event: { affectsConfiguration: (section: string) => boolean }) => unknown
|
||||
>,
|
||||
availableCommandsCallbackRef: {
|
||||
current: undefined as
|
||||
| ((commands: Array<{ name: string; description?: string }>) => void)
|
||||
| undefined,
|
||||
},
|
||||
mockCreateImagePathResolver: vi.fn(),
|
||||
mockConfigGet: vi.fn(),
|
||||
mockConfigUpdate: vi.fn(),
|
||||
mockGetGlobalTempDir: vi.fn(() => '/global-temp'),
|
||||
mockGetPanel: vi.fn<() => { webview: { postMessage: unknown } } | null>(
|
||||
() => null,
|
||||
|
|
@ -34,9 +47,29 @@ const {
|
|||
data: { optionId?: string };
|
||||
}) => void;
|
||||
}>,
|
||||
mockOnDidChangeConfiguration: vi.fn(
|
||||
(
|
||||
handler: (event: {
|
||||
affectsConfiguration: (section: string) => boolean;
|
||||
}) => unknown,
|
||||
) => {
|
||||
mockConfigChangeHandlers.push(handler);
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
),
|
||||
mockOnDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
mockOnDidChangeTextEditorSelection: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
mockOpenExternal: vi.fn(),
|
||||
mockReadQwenSettingsForVSCode: vi.fn<
|
||||
() => {
|
||||
provider: 'coding-plan' | 'api-key';
|
||||
apiKey: string;
|
||||
codingPlanRegion: 'china' | 'global';
|
||||
} | null
|
||||
>(() => null),
|
||||
mockWriteCodingPlanConfig: vi.fn(() => ({})),
|
||||
mockWriteModelProvidersConfig: vi.fn(),
|
||||
mockClearPersistedAuth: vi.fn(),
|
||||
slashCommandNotificationCallbackRef: {
|
||||
current: undefined as
|
||||
| ((event: {
|
||||
|
|
@ -50,6 +83,7 @@ const {
|
|||
mockQwenAgentManagerInstances: [] as Array<{
|
||||
permissionRequestCallback?: (request: unknown) => Promise<string>;
|
||||
cancelCurrentPrompt: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
}));
|
||||
|
||||
|
|
@ -66,6 +100,9 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
|
|||
});
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
ConfigurationTarget: {
|
||||
Global: 'global',
|
||||
},
|
||||
Uri: {
|
||||
joinPath: vi.fn((base: { fsPath?: string }, ...parts: string[]) => ({
|
||||
fsPath: `${base.fsPath ?? ''}/${parts.join('/')}`.replace(/\/+/g, '/'),
|
||||
|
|
@ -82,12 +119,24 @@ vi.mock('vscode', () => ({
|
|||
},
|
||||
workspace: {
|
||||
workspaceFolders: [{ uri: { fsPath: '/workspace-root' } }],
|
||||
onDidChangeConfiguration: mockOnDidChangeConfiguration,
|
||||
getConfiguration: vi.fn(() => ({
|
||||
get: mockConfigGet,
|
||||
update: mockConfigUpdate,
|
||||
})),
|
||||
},
|
||||
commands: {
|
||||
executeCommand: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/settingsWriter.js', () => ({
|
||||
writeCodingPlanConfig: mockWriteCodingPlanConfig,
|
||||
writeModelProvidersConfig: mockWriteModelProvidersConfig,
|
||||
readQwenSettingsForVSCode: mockReadQwenSettingsForVSCode,
|
||||
clearPersistedAuth: mockClearPersistedAuth,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/qwenAgentManager.js', () => ({
|
||||
QwenAgentManager: class {
|
||||
isConnected = false;
|
||||
|
|
@ -179,7 +228,7 @@ vi.mock('./MessageHandler.js', () => ({
|
|||
) {
|
||||
mockMessageHandlerInstances.push(this);
|
||||
}
|
||||
setLoginHandler = vi.fn();
|
||||
setAuthInteractiveHandler = vi.fn();
|
||||
permissionHandler?: (message: {
|
||||
type: string;
|
||||
data: { optionId?: string };
|
||||
|
|
@ -227,6 +276,10 @@ import {
|
|||
MAX_PANEL_TITLE_LENGTH,
|
||||
} from '../utils/panelTitleUtils.js';
|
||||
|
||||
const createConfigChangeEvent = (...affectedSections: string[]) => ({
|
||||
affectsConfiguration: (section: string) => affectedSections.includes(section),
|
||||
});
|
||||
|
||||
type WebViewMessageHandler = (message: {
|
||||
type: string;
|
||||
data?: unknown;
|
||||
|
|
@ -278,12 +331,19 @@ async function setupAttachedProvider(options?: {
|
|||
return { webview, postMessage, provider, messageHandler };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfigChangeHandlers.length = 0;
|
||||
});
|
||||
|
||||
describe('WebViewProvider.attachToView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMessageHandlerInstances.length = 0;
|
||||
mockQwenAgentManagerInstances.length = 0;
|
||||
mockGetPanel.mockReturnValue(null);
|
||||
mockConfigGet.mockImplementation(
|
||||
(_key: string, defaultValue: unknown) => defaultValue,
|
||||
);
|
||||
availableCommandsCallbackRef.current = undefined;
|
||||
slashCommandNotificationCallbackRef.current = undefined;
|
||||
mockCreateImagePathResolver.mockReturnValue((paths: string[]) =>
|
||||
|
|
@ -666,6 +726,231 @@ describe('WebViewProvider.attachToView', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('WebViewProvider settings sync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfigChangeHandlers.length = 0;
|
||||
mockConfigGet.mockImplementation(
|
||||
(_key: string, defaultValue: unknown) => defaultValue,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not report success for api-key settings without interactive auth data', async () => {
|
||||
mockConfigGet.mockImplementation((key: string, defaultValue: unknown) => {
|
||||
if (key === 'apiKey') {
|
||||
return 'sk-test';
|
||||
}
|
||||
if (key === 'provider') {
|
||||
return 'api-key';
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const provider = new WebViewProvider(
|
||||
{ subscriptions: [] } as never,
|
||||
{ fsPath: '/extension-root' } as never,
|
||||
);
|
||||
|
||||
const synced = await (
|
||||
provider as unknown as {
|
||||
syncVSCodeSettingsToQwenConfig: () => Promise<boolean>;
|
||||
}
|
||||
).syncVSCodeSettingsToQwenConfig();
|
||||
|
||||
expect(synced).toBe(false);
|
||||
expect(mockWriteCodingPlanConfig).not.toHaveBeenCalled();
|
||||
expect(mockWriteModelProvidersConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only syncs non-secret VS Code settings from ~/.qwen/settings.json', async () => {
|
||||
mockReadQwenSettingsForVSCode.mockReturnValue({
|
||||
provider: 'coding-plan',
|
||||
apiKey: 'sk-updated',
|
||||
codingPlanRegion: 'global',
|
||||
});
|
||||
mockConfigGet.mockImplementation((key: string, defaultValue: unknown) => {
|
||||
if (key === 'provider') {
|
||||
return 'api-key';
|
||||
}
|
||||
if (key === 'apiKey') {
|
||||
return 'sk-current';
|
||||
}
|
||||
if (key === 'codingPlanRegion') {
|
||||
return 'china';
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const provider = new WebViewProvider(
|
||||
{ subscriptions: [] } as never,
|
||||
{ fsPath: '/extension-root' } as never,
|
||||
);
|
||||
|
||||
await (
|
||||
provider as unknown as {
|
||||
syncQwenConfigToVSCodeSettings: () => Promise<void>;
|
||||
}
|
||||
).syncQwenConfigToVSCodeSettings();
|
||||
|
||||
expect(mockConfigUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(mockConfigUpdate).toHaveBeenCalledWith(
|
||||
'provider',
|
||||
'coding-plan',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockConfigUpdate).toHaveBeenCalledWith(
|
||||
'codingPlanRegion',
|
||||
'global',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockConfigUpdate).not.toHaveBeenCalledWith(
|
||||
'apiKey',
|
||||
'sk-updated',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores non-auth qwen-code setting changes', async () => {
|
||||
const provider = new WebViewProvider(
|
||||
{ subscriptions: [] } as never,
|
||||
{ fsPath: '/extension-root' } as never,
|
||||
);
|
||||
const syncSpy = vi
|
||||
.spyOn(
|
||||
provider as unknown as {
|
||||
syncVSCodeSettingsToQwenConfig: () => Promise<boolean>;
|
||||
},
|
||||
'syncVSCodeSettingsToQwenConfig',
|
||||
)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const configChangeHandler = mockConfigChangeHandlers.at(-1);
|
||||
expect(configChangeHandler).toBeDefined();
|
||||
|
||||
await configChangeHandler?.(createConfigChangeEvent('qwen-code'));
|
||||
|
||||
expect(syncSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reacts to auth-related qwen-code setting changes', async () => {
|
||||
const provider = new WebViewProvider(
|
||||
{ subscriptions: [] } as never,
|
||||
{ fsPath: '/extension-root' } as never,
|
||||
);
|
||||
const syncSpy = vi
|
||||
.spyOn(
|
||||
provider as unknown as {
|
||||
syncVSCodeSettingsToQwenConfig: () => Promise<boolean>;
|
||||
},
|
||||
'syncVSCodeSettingsToQwenConfig',
|
||||
)
|
||||
.mockResolvedValue(false);
|
||||
|
||||
const configChangeHandler = mockConfigChangeHandlers.at(-1);
|
||||
expect(configChangeHandler).toBeDefined();
|
||||
|
||||
await configChangeHandler?.(
|
||||
createConfigChangeEvent('qwen-code', 'qwen-code.apiKey'),
|
||||
);
|
||||
|
||||
expect(syncSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears persisted credentials and disconnects when apiKey is emptied', async () => {
|
||||
const provider = new WebViewProvider(
|
||||
{ subscriptions: [] } as never,
|
||||
{ fsPath: '/extension-root' } as never,
|
||||
);
|
||||
|
||||
// Simulate an already-initialized agent connection
|
||||
(provider as unknown as { agentInitialized: boolean }).agentInitialized =
|
||||
true;
|
||||
|
||||
// syncVSCodeSettingsToQwenConfig returns false because apiKey is empty
|
||||
vi.spyOn(
|
||||
provider as unknown as {
|
||||
syncVSCodeSettingsToQwenConfig: () => Promise<boolean>;
|
||||
},
|
||||
'syncVSCodeSettingsToQwenConfig',
|
||||
).mockResolvedValue(false);
|
||||
|
||||
// apiKey is empty (user cleared it in Settings)
|
||||
mockConfigGet.mockImplementation((key: string, defaultValue: unknown) => {
|
||||
if (key === 'apiKey') {
|
||||
return '';
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const configChangeHandler = mockConfigChangeHandlers.at(-1);
|
||||
expect(configChangeHandler).toBeDefined();
|
||||
|
||||
await configChangeHandler?.(
|
||||
createConfigChangeEvent('qwen-code', 'qwen-code.apiKey'),
|
||||
);
|
||||
|
||||
// Should clear persisted auth
|
||||
expect(mockClearPersistedAuth).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Should disconnect the agent
|
||||
const agentManager = mockQwenAgentManagerInstances.at(-1);
|
||||
expect(agentManager?.disconnect).toHaveBeenCalledTimes(1);
|
||||
|
||||
// agentInitialized should be reset
|
||||
expect(
|
||||
(provider as unknown as { agentInitialized: boolean }).agentInitialized,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not de-auth when non-apiKey auth settings change on an api-key provider', async () => {
|
||||
const provider = new WebViewProvider(
|
||||
{ subscriptions: [] } as never,
|
||||
{ fsPath: '/extension-root' } as never,
|
||||
);
|
||||
|
||||
// Simulate an already-initialized agent with api-key provider
|
||||
(provider as unknown as { agentInitialized: boolean }).agentInitialized =
|
||||
true;
|
||||
|
||||
// syncVSCodeSettingsToQwenConfig returns false — normal for api-key providers
|
||||
vi.spyOn(
|
||||
provider as unknown as {
|
||||
syncVSCodeSettingsToQwenConfig: () => Promise<boolean>;
|
||||
},
|
||||
'syncVSCodeSettingsToQwenConfig',
|
||||
).mockResolvedValue(false);
|
||||
|
||||
// apiKey is empty because api-key providers don't use this VS Code setting
|
||||
mockConfigGet.mockImplementation((key: string, defaultValue: unknown) => {
|
||||
if (key === 'apiKey') {
|
||||
return '';
|
||||
}
|
||||
if (key === 'provider') {
|
||||
return 'api-key';
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const configChangeHandler = mockConfigChangeHandlers.at(-1);
|
||||
expect(configChangeHandler).toBeDefined();
|
||||
|
||||
// Changing codingPlanRegion should NOT trigger de-auth
|
||||
await configChangeHandler?.(
|
||||
createConfigChangeEvent('qwen-code', 'qwen-code.codingPlanRegion'),
|
||||
);
|
||||
|
||||
expect(mockClearPersistedAuth).not.toHaveBeenCalled();
|
||||
|
||||
const agentManager = mockQwenAgentManagerInstances.at(-1);
|
||||
expect(agentManager?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
// agentInitialized should remain true
|
||||
expect(
|
||||
(provider as unknown as { agentInitialized: boolean }).agentInitialized,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebViewProvider.createNewSession', () => {
|
||||
it('forces a fresh ACP session for the sidebar new-session action', async () => {
|
||||
const provider = new WebViewProvider(
|
||||
|
|
|
|||
|
|
@ -26,8 +26,20 @@ import { createImagePathResolver } from '../utils/imageHandler.js';
|
|||
import { type ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
|
||||
import { isAuthenticationRequiredError } from '../../utils/authErrors.js';
|
||||
import { getErrorMessage } from '../../utils/errorMessage.js';
|
||||
import {
|
||||
writeCodingPlanConfig,
|
||||
writeModelProvidersConfig,
|
||||
readQwenSettingsForVSCode,
|
||||
clearPersistedAuth,
|
||||
} from '../../services/settingsWriter.js';
|
||||
import { parseInsightMessage } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const AUTH_RELATED_QWEN_SETTINGS = [
|
||||
'qwen-code.provider',
|
||||
'qwen-code.apiKey',
|
||||
'qwen-code.codingPlanRegion',
|
||||
] as const;
|
||||
|
||||
function isInsightCommand(command: string): boolean {
|
||||
const [firstToken = ''] = command.trim().split(/\s+/, 1);
|
||||
return firstToken.replace(/^\/+/, '') === 'insight';
|
||||
|
|
@ -40,6 +52,7 @@ export class WebViewProvider {
|
|||
private conversationStore: ConversationStore;
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
private agentInitialized = false; // Track if agent has been initialized
|
||||
private isSyncingToVSCode = false; // Guard to prevent config change loop
|
||||
// Track a pending permission request and its resolver so extension commands
|
||||
// can "simulate" user choice from the command palette (e.g. after accepting
|
||||
// a diff, auto-allow read/execute, or auto-reject on cancel).
|
||||
|
|
@ -70,6 +83,10 @@ export class WebViewProvider {
|
|||
/** Guards against concurrent auth-restore / connection init */
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
private isReconnecting = false;
|
||||
/** Timer for the deferred auto-auth launch inside doInitializeAgentConnection */
|
||||
private autoAuthTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Whether an explicit interactive auth flow is currently active */
|
||||
private authFlowActive = false;
|
||||
|
||||
constructor(
|
||||
private context: vscode.ExtensionContext,
|
||||
|
|
@ -100,10 +117,78 @@ export class WebViewProvider {
|
|||
(message) => this.sendMessageToWebView(message),
|
||||
);
|
||||
|
||||
// Set login handler for /login command - direct force re-login
|
||||
this.messageHandler.setLoginHandler(async () => {
|
||||
await this.forceReLogin();
|
||||
});
|
||||
// Set auth interactive handler — interactive auth flow (QuickPick → InputBox → write settings → reconnect)
|
||||
this.messageHandler.setAuthInteractiveHandler(
|
||||
async (provider, region, apiKey, baseUrl, model, modelIds) => {
|
||||
await this.handleAuthInteractive(
|
||||
provider,
|
||||
region,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
modelIds,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Watch for auth-related VSCode settings changes — auto-sync and reconnect.
|
||||
// The isSyncingToVSCode guard prevents a loop when we programmatically populate VSCode settings.
|
||||
const configChangeDisposable = vscode.workspace.onDidChangeConfiguration(
|
||||
async (e) => {
|
||||
const authSettingsChanged = AUTH_RELATED_QWEN_SETTINGS.some((setting) =>
|
||||
e.affectsConfiguration(setting),
|
||||
);
|
||||
|
||||
if (authSettingsChanged && !this.isSyncingToVSCode) {
|
||||
console.log(
|
||||
'[WebViewProvider] Auth-related qwen-code settings changed by user, syncing...',
|
||||
);
|
||||
const synced = await this.syncVSCodeSettingsToQwenConfig();
|
||||
if (synced && this.agentInitialized) {
|
||||
// Settings changed and we have an active connection — reconnect
|
||||
try {
|
||||
this.agentManager.disconnect();
|
||||
this.agentInitialized = false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await this.doInitializeAgentConnection({
|
||||
autoAuthenticate: false,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'[WebViewProvider] Reconnect after settings change failed:',
|
||||
e,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
!synced &&
|
||||
this.agentInitialized &&
|
||||
e.affectsConfiguration('qwen-code.apiKey')
|
||||
) {
|
||||
// Only de-auth when qwen-code.apiKey itself was cleared.
|
||||
// Other auth-related settings (provider, codingPlanRegion) returning
|
||||
// synced=false is normal for api-key providers — those are managed by
|
||||
// the interactive auth flow, not VS Code Settings sync.
|
||||
const apiKey = vscode.workspace
|
||||
.getConfiguration('qwen-code')
|
||||
.get<string>('apiKey', '');
|
||||
if (!apiKey) {
|
||||
console.log(
|
||||
'[WebViewProvider] apiKey cleared — de-authenticating and clearing persisted credentials',
|
||||
);
|
||||
clearPersistedAuth();
|
||||
this.agentManager.disconnect();
|
||||
this.agentInitialized = false;
|
||||
this.authState = false;
|
||||
this.sendMessageToWebView({
|
||||
type: 'authState',
|
||||
data: { authenticated: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
this.disposables.push(configChangeDisposable);
|
||||
|
||||
// Setup file watchers for cache invalidation
|
||||
const fileWatcherDisposable = this.messageHandler.setupFileWatchers();
|
||||
|
|
@ -866,6 +951,29 @@ export class WebViewProvider {
|
|||
await this.attemptAuthStateRestoration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the interactive auth flow (QuickPick → InputBox → write settings → reconnect).
|
||||
* Guards against concurrent launches: if auto-auth was scheduled by
|
||||
* doInitializeAgentConnection's deferred timeout, it is cancelled first.
|
||||
*/
|
||||
async startInteractiveAuth(): Promise<void> {
|
||||
// Cancel any pending auto-auth from doInitializeAgentConnection so we
|
||||
// don't end up with two overlapping auth flows.
|
||||
if (this.autoAuthTimer) {
|
||||
clearTimeout(this.autoAuthTimer);
|
||||
this.autoAuthTimer = null;
|
||||
}
|
||||
if (this.authFlowActive) {
|
||||
return;
|
||||
}
|
||||
this.authFlowActive = true;
|
||||
try {
|
||||
await this.messageHandler.route({ type: 'auth' });
|
||||
} finally {
|
||||
this.authFlowActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
setInitialModelId(modelId: string | null | undefined): void {
|
||||
this.initialModelId =
|
||||
typeof modelId === 'string' && modelId.trim().length > 0
|
||||
|
|
@ -874,8 +982,113 @@ export class WebViewProvider {
|
|||
}
|
||||
|
||||
/**
|
||||
* Attempt to restore authentication state and initialize connection
|
||||
* This is called when the webview is first shown
|
||||
* Sync VSCode extension settings (qwen-code.*) to ~/.qwen/settings.json
|
||||
* if an API key is configured. This enables auto-connect on startup
|
||||
* without requiring the user to click "Connect" each time.
|
||||
*
|
||||
* @returns true if settings were synced (apiKey is configured), false otherwise
|
||||
*/
|
||||
private async syncVSCodeSettingsToQwenConfig(): Promise<boolean> {
|
||||
const config = vscode.workspace.getConfiguration('qwen-code');
|
||||
const apiKey = config.get<string>('apiKey', '');
|
||||
|
||||
if (!apiKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = config.get<string>('provider', 'coding-plan');
|
||||
|
||||
if (provider !== 'coding-plan') {
|
||||
console.log(
|
||||
'[WebViewProvider] Skipping VSCode settings sync for api-key provider; interactive auth owns provider details',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const region = config.get<'china' | 'global'>(
|
||||
'codingPlanRegion',
|
||||
'china',
|
||||
);
|
||||
writeCodingPlanConfig(region, apiKey);
|
||||
|
||||
console.log(
|
||||
`[WebViewProvider] Synced VSCode settings → ~/.qwen/settings.json (provider=${provider})`,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[WebViewProvider] Failed to sync VSCode settings:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync ~/.qwen/settings.json values back to VSCode Settings UI.
|
||||
* This makes existing CLI-configured non-secret metadata visible in the
|
||||
* VSCode Settings page without mirroring credentials into settings.json.
|
||||
*/
|
||||
private async syncQwenConfigToVSCodeSettings(): Promise<void> {
|
||||
try {
|
||||
const qwenSettings = readQwenSettingsForVSCode();
|
||||
if (!qwenSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[WebViewProvider] Syncing ~/.qwen/settings.json → VSCode settings',
|
||||
);
|
||||
|
||||
// Set guard to prevent onDidChangeConfiguration from triggering a write-back
|
||||
const config = vscode.workspace.getConfiguration('qwen-code');
|
||||
const target = vscode.ConfigurationTarget.Global;
|
||||
const updates: Array<Thenable<void>> = [];
|
||||
|
||||
if (
|
||||
config.get<string>('provider', 'coding-plan') !== qwenSettings.provider
|
||||
) {
|
||||
updates.push(config.update('provider', qwenSettings.provider, target));
|
||||
}
|
||||
if (
|
||||
config.get<'china' | 'global'>('codingPlanRegion', 'china') !==
|
||||
qwenSettings.codingPlanRegion
|
||||
) {
|
||||
updates.push(
|
||||
config.update(
|
||||
'codingPlanRegion',
|
||||
qwenSettings.codingPlanRegion,
|
||||
target,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
console.log(
|
||||
'[WebViewProvider] VSCode settings already match ~/.qwen/settings.json',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSyncingToVSCode = true;
|
||||
|
||||
try {
|
||||
await Promise.all(updates);
|
||||
} finally {
|
||||
this.isSyncingToVSCode = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[WebViewProvider] Failed to sync qwen config to VSCode settings:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to restore authentication state and initialize connection.
|
||||
* On startup, sync ~/.qwen/settings.json → VSCode settings so the Settings UI
|
||||
* reflects existing non-secret CLI config, then attempt a connection.
|
||||
* Writing back to ~/.qwen/settings.json happens through the auth flow and
|
||||
* auth-related VSCode setting changes.
|
||||
*/
|
||||
private async attemptAuthStateRestoration(): Promise<void> {
|
||||
// Prevent concurrent initialization attempts (e.g. visibility toggle + webviewReady race)
|
||||
|
|
@ -885,6 +1098,8 @@ export class WebViewProvider {
|
|||
|
||||
this.initializationPromise = (async () => {
|
||||
try {
|
||||
await this.syncQwenConfigToVSCodeSettings();
|
||||
|
||||
console.log('[WebViewProvider] Attempting connection...');
|
||||
// Attempt a connection to detect prior auth without forcing login
|
||||
await this.initializeAgentConnection({ autoAuthenticate: false });
|
||||
|
|
@ -954,7 +1169,7 @@ export class WebViewProvider {
|
|||
// send authState message and return without creating session
|
||||
if (connectResult.requiresAuth && !autoAuthenticate) {
|
||||
console.log(
|
||||
'[WebViewProvider] Authentication required but auto-auth disabled, sending authState and returning',
|
||||
'[WebViewProvider] Authentication required, launching auth flow...',
|
||||
);
|
||||
this.sendMessageToWebView({
|
||||
type: 'authState',
|
||||
|
|
@ -962,6 +1177,22 @@ export class WebViewProvider {
|
|||
});
|
||||
// Initialize empty conversation to allow browsing history
|
||||
await this.initializeEmptyConversation();
|
||||
|
||||
// Auto-launch the interactive auth flow (QuickPick → InputBox)
|
||||
// so the user is immediately guided to configure their provider,
|
||||
// mirroring CLI's behavior of showing AuthDialog on first run.
|
||||
// Deferred to avoid conflicting with the current connection init.
|
||||
// The timer is stored so startInteractiveAuth() can cancel it
|
||||
// to prevent two overlapping auth flows.
|
||||
this.autoAuthTimer = setTimeout(() => {
|
||||
this.autoAuthTimer = null;
|
||||
if (!this.authFlowActive) {
|
||||
this.authFlowActive = true;
|
||||
void this.messageHandler.route({ type: 'auth' }).finally(() => {
|
||||
this.authFlowActive = false;
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1009,70 +1240,100 @@ export class WebViewProvider {
|
|||
}
|
||||
|
||||
/**
|
||||
* Force re-login by clearing auth cache and reconnecting
|
||||
* Called when user explicitly uses /login command
|
||||
* Handle auth interactive — interactive auth flow result.
|
||||
* Writes provider config to ~/.qwen/settings.json and reconnects.
|
||||
* Mirrors the CLI's `qwen auth coding-plan` / `qwen auth` flow.
|
||||
*/
|
||||
async forceReLogin(): Promise<void> {
|
||||
console.log('[WebViewProvider] Force re-login requested');
|
||||
private async handleAuthInteractive(
|
||||
provider: string,
|
||||
region?: string,
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
model?: string,
|
||||
modelIds?: string,
|
||||
): Promise<void> {
|
||||
if (!apiKey) {
|
||||
this.sendMessageToWebView({
|
||||
type: 'authError',
|
||||
data: { message: 'API key is required.' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
cancellable: false,
|
||||
},
|
||||
async (progress) => {
|
||||
try {
|
||||
progress.report({ message: 'Preparing sign-in...' });
|
||||
|
||||
// Disconnect existing connection if any
|
||||
if (this.agentInitialized) {
|
||||
try {
|
||||
this.agentManager.disconnect();
|
||||
console.log('[WebViewProvider] Existing connection disconnected');
|
||||
} catch (_error) {
|
||||
console.log('[WebViewProvider] Error disconnecting:', _error);
|
||||
}
|
||||
this.agentInitialized = false;
|
||||
}
|
||||
|
||||
// Wait a moment for cleanup to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
progress.report({
|
||||
message: 'Connecting to CLI and starting sign-in...',
|
||||
});
|
||||
|
||||
// Reinitialize connection (will trigger fresh authentication)
|
||||
await this.doInitializeAgentConnection({ autoAuthenticate: true });
|
||||
console.log(
|
||||
'[WebViewProvider] Force re-login completed successfully',
|
||||
);
|
||||
|
||||
// Send success notification to WebView
|
||||
this.sendMessageToWebView({
|
||||
type: 'loginSuccess',
|
||||
data: { message: 'Successfully logged in!' },
|
||||
});
|
||||
} catch (_error) {
|
||||
const errorMsg = getErrorMessage(_error);
|
||||
console.error('[WebViewProvider] Force re-login failed:', _error);
|
||||
console.error(
|
||||
'[WebViewProvider] Error stack:',
|
||||
_error instanceof Error ? _error.stack : 'N/A',
|
||||
);
|
||||
|
||||
// Send error notification to WebView
|
||||
this.sendMessageToWebView({
|
||||
type: 'loginError',
|
||||
data: {
|
||||
message: `Login failed: ${errorMsg}`,
|
||||
},
|
||||
});
|
||||
|
||||
throw _error;
|
||||
}
|
||||
},
|
||||
console.log(
|
||||
`[WebViewProvider] authInteractive: provider=${provider}, region=${region}, model=${model}`,
|
||||
);
|
||||
|
||||
try {
|
||||
if (provider === 'coding-plan') {
|
||||
writeCodingPlanConfig(region === 'global' ? 'global' : 'china', apiKey);
|
||||
} else if (provider === 'alibaba-standard') {
|
||||
// Alibaba Standard — multiple models sharing the same base URL
|
||||
const modelBaseUrl =
|
||||
baseUrl || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
const ids = (modelIds || model || 'qwen3.5-plus')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const providers: Record<string, string> = {};
|
||||
for (const id of ids) {
|
||||
providers[id] = modelBaseUrl;
|
||||
}
|
||||
writeModelProvidersConfig({
|
||||
apiKey,
|
||||
modelProviders: providers,
|
||||
activeModel: ids[0] || 'qwen3.5-plus',
|
||||
});
|
||||
} else {
|
||||
// Custom API Key — single model entry
|
||||
const modelId = model || 'default';
|
||||
const modelBaseUrl = baseUrl || 'https://api.openai.com/v1';
|
||||
writeModelProvidersConfig({
|
||||
apiKey,
|
||||
modelProviders: { [modelId]: modelBaseUrl },
|
||||
activeModel: modelId,
|
||||
});
|
||||
}
|
||||
|
||||
// Disconnect + reconnect
|
||||
if (this.agentInitialized) {
|
||||
try {
|
||||
this.agentManager.disconnect();
|
||||
} catch (e) {
|
||||
console.log('[WebViewProvider] Error disconnecting:', e);
|
||||
}
|
||||
this.agentInitialized = false;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await this.doInitializeAgentConnection({ autoAuthenticate: false });
|
||||
|
||||
// Only emit authSuccess when the reconnection actually authenticated.
|
||||
// doInitializeAgentConnection updates this.authState via sendMessageToWebView;
|
||||
// if credentials were rejected, authState will be false and we should not
|
||||
// claim success (which would briefly show a success toast then re-open auth).
|
||||
if (this.authState === true) {
|
||||
this.sendMessageToWebView({
|
||||
type: 'authSuccess',
|
||||
data: { message: 'Provider configured successfully!' },
|
||||
});
|
||||
} else {
|
||||
this.sendMessageToWebView({
|
||||
type: 'authError',
|
||||
data: {
|
||||
message:
|
||||
'Connection established but authentication failed. Please check your credentials.',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
console.error('[WebViewProvider] authInteractive failed:', error);
|
||||
this.sendMessageToWebView({
|
||||
type: 'authError',
|
||||
data: { message: `Configuration failed: ${errorMsg}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1324,11 +1585,11 @@ export class WebViewProvider {
|
|||
}
|
||||
break;
|
||||
case 'agentConnected':
|
||||
case 'loginSuccess':
|
||||
case 'authSuccess':
|
||||
this.authState = true;
|
||||
break;
|
||||
case 'agentConnectionError':
|
||||
case 'loginError':
|
||||
case 'authError':
|
||||
this.authState = false;
|
||||
break;
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -187,14 +187,17 @@ export const PermissionDrawer: FC<PermissionDrawerProps> = ({
|
|||
return null;
|
||||
}
|
||||
for (const item of toolCall.content) {
|
||||
const itemType = item['type'];
|
||||
const itemContent = item['content'];
|
||||
|
||||
if (
|
||||
item.type === 'content' &&
|
||||
typeof item.content === 'object' &&
|
||||
item.content !== null
|
||||
itemType === 'content' &&
|
||||
typeof itemContent === 'object' &&
|
||||
itemContent !== null
|
||||
) {
|
||||
const inner = item.content as { type?: string; text?: string };
|
||||
if (inner.type === 'text' && typeof inner.text === 'string') {
|
||||
return inner.text;
|
||||
const inner = itemContent as Record<string, unknown>;
|
||||
if (inner['type'] === 'text' && typeof inner['text'] === 'string') {
|
||||
return inner['text'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue